Mastering Terraform Provisioners

In today’s blog post, we will learn what are terraform provisioners, why we need provisioners, explore provisioners types with examples, provisioners meta arguments, and finally its use cases. So without any further delay, let us get started.

What are Terraform Provisioners?

Terraform Provisioners are blocks of code defined within a resource block (or a null_resource block) that execute some actions on a local or remote machine after a resource has been created or before it is destroyed. They allow you to perform bootstrapping, configuration management, or cleanup tasks directly from your Terraform configuration.

Think of them as hooks: they let you run scripts or commands at specific points in the resource lifecycle.

Why Use Provisioners (and their limitations)?

Pros:

  • Initial Setup: Perform basic setup on a new server (e.g., install a web server, update packages).
  • Bootstrapping: Inject configuration or run a script that kicks off further automation (e.g., registering with a configuration management system).
  • Simplicity for Small Tasks: For very simple, one-off configuration tasks, they can be quicker than setting up a full-blown configuration management tool.
  • Cleanup: Execute commands before a resource is fully destroyed.

Cons:

  • Lack of Idempotency: Provisioners run arbitrary commands. It is your responsibility to ensure these commands are idempotent (running them multiple times yields the same result) – something configuration management tools handle inherently.
  • State Management Issues: If a provisioner fails, Terraform might mark the resource as “tainted,” requiring manual intervention. They do not easily integrate into Terraform’s declarative state.
  • Limited Error Handling: While there is an on_failure option, complex error handling is difficult.
  • Reduced Visibility: The configuration executed by provisioners is outside Terraform’s declarative state, making it harder to inspect the final state of the configured system.
  • Security Concerns: Remote execution requires credentials (SSH keys, WinRM credentials) to be managed, potentially introducing security risks if not handled carefully.
  • Single-Run Nature: Provisioners run only when a resource is created or tainted. They would not re-run (exception – always_run) if you change an input variable for the provisioner itself (unless the resource is destroyed and recreated, or tainted).

General Rule of Thumb: Use provisioners as a last resort for tasks that can not be handled by declarative resource configuration or a dedicated configuration management tool.

Types of Terraform Provisioners

Terraform offers several built-in provisioners:

  1. file: Copies files from the local machine to the remote resource.
  2. local-exec: Executes a command on the machine running Terraform.
  3. remote-exec: Executes a command on the remote resource created by Terraform.
  4. null_resource (with provisioners): A special resource type used when you want to run provisioners independent of other resources.

The connection Block

For file and remote-exec provisioners, you almost always need a connection block to specify how Terraform should connect to the remote machine.

connection {
  type     = "ssh"        # or "winrm"
  user     = "ec2-user"   # Username for SSH
  private_key = file("~/.ssh/id_rsa") # Path to your private key
  host     = self.public_ip # The IP address of the instance
  timeout  = "5m"         # Optional: How long to wait for connection
}

Common connection arguments:

  • type: ssh (Linux) or winrm (Windows).
  • user: Username for the connection.
  • password: (Avoid if possible, use private keys/secrets management).
  • private_key: Path to the SSH private key file.
  • host: The IP address or DNS name of the remote machine.
  • timeout: How long to wait for the connection to establish before timing out.
  • port: The port for the connection (default: 22 for SSH, 5985/5986 for WinRM).

Terraform Provisioner Examples:

file Provisioner: Copying Files

The file provisioner copies files or directories from the machine running Terraform to a remote resource.

resource "aws_instance" "web_server" {
  ami           = "ami-0abcdef1234567890" # Replace with a valid AMI
  instance_type = "t2.micro"
  key_name      = "my-ssh-key" # Ensure this key exists

  provisioner "file" {
    source      = "scripts/install_nginx.sh" # Path to local script
    destination = "/tmp/install_nginx.sh"    # Path on remote machine

    connection {
      type        = "ssh"
      user        = "ec2-user"
      private_key = file("~/.ssh/id_rsa")
      host        = self.public_ip
    }
  }

  tags = {
    Name = "Web Server"
  }
}

Explanation:

  • source: The path to the file or directory on your local machine.
  • destination: The absolute path on the remote machine where the file/directory should be copied.

remote-exec Provisioner: Running Commands on Remote Machines

The remote-exec provisioner executes scripts or commands on the remote resource. This is often used after a file provisioner has copied a script.

resource "aws_instance" "web_server" {
  ami           = "ami-0abcdef1234567890"
  instance_type = "t2.micro"
  key_name      = "my-ssh-key"

  provisioner "file" {
    source      = "scripts/install_nginx.sh"
    destination = "/tmp/install_nginx.sh"
    connection { /* ... (same as above) ... */ }
  }

  provisioner "remote-exec" {
    inline = [
      "chmod +x /tmp/install_nginx.sh", # Make script executable
      "/tmp/install_nginx.sh"           # Execute the script
    ]
    connection { /* ... (same as above) ... */ }
  }

  tags = {
    Name = "Web Server"
  }
}

Explanation:

  • inline: A list of strings, where each string is a command to be executed.
  • script: (Alternative to inline) Path to a local script that will be copied to the remote machine and executed. This is useful for longer scripts.
provisioner "remote-exec" {
    script = "scripts/configure_webserver.sh" # This script will be copied and executed
    connection { /* ... */ }
  }

local-exec Provisioner: Running Commands Locally

The local-exec provisioner executes a command on the machine where terraform apply is run, not on the created resource.

resource "aws_s3_bucket" "my_app_bucket" {
  bucket = "my-unique-app-bucket-12345"
  acl    = "private"
}

resource "null_resource" "post_bucket_creation" {
  # This resource has no infrastructure backing, only provisioners.
  # It depends on the S3 bucket to ensure it runs after the bucket is created.
  depends_on = [aws_s3_bucket.my_app_bucket]

  provisioner "local-exec" {
    command = "echo 'S3 bucket ${aws_s3_bucket.my_app_bucket.bucket} created successfully!' >> terraform_logs.txt"
  }

  provisioner "local-exec" {
    # Example: Triggering a notification or another local script
    command = "curl -X POST -H 'Content-type: application/json' --data '{\"text\":\"S3 bucket ${aws_s3_bucket.my_app_bucket.bucket} provisioned!\"}' ${var.slack_webhook_url}"
  }
}

Explanation:

  • command: The command to execute on the local machine.
  • working_directory: (Optional) The directory from which to run the command.
  • environment: (Optional) A map of environment variables to set for the command.

Common local-exec use cases:

  • Triggering external scripts or CI/CD pipelines.
  • Generating configuration files locally.
  • Sending notifications (as shown above).
  • Running local tests or validations.

null_resource: Running Provisioners Independently

The null_resource is a special resource in Terraform that does not map to any cloud or infrastructure object. Its sole purpose is to serve as a container for provisioners. It is very useful when you need to run commands that are logically tied to your Terraform deployment but do not configure a specific infrastructure resource directly.

You often combine null_resource with the triggers argument to make it sensitive to changes in other resources or variables, forcing its provisioners to re-run.

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
  tags = {
    Name = "MyVPC"
  }
}

resource "null_resource" "vpc_config_script" {
  # This makes the null_resource re-run its provisioners if the VPC ID changes
  triggers = {
    vpc_id = aws_vpc.main.id
  }

  provisioner "local-exec" {
    command = "echo 'VPC with ID ${self.triggers.vpc_id} created.' >> vpc_creation_log.txt"
  }

  provisioner "local-exec" {
    # Imagine configuring a Firewall rule that depends on the VPC ID externally
    command = "python external_firewall_config.py --vpc-id ${self.triggers.vpc_id}"
  }
}

Explanation:

  • triggers: A map of arbitrary values. If any value in the triggers map changes between terraform apply runs, the null_resource is “tainted” and its provisioners will re-run. This is how you force re-execution of provisioners tied to the null_resource.
  • self.triggers.vpc_id: This accesses the value from the triggers map.

Provisioner Lifecycle and Error Handling

Provisioners run in a specific order and have options for error handling:

  • Default Behavior: By default, provisioners run after a resource is created.
  • on_failure: Controls what happens if a provisioner fails.
    • continue (default): Terraform proceeds with the apply even if the provisioner fails.
    • fail: Terraform stops the apply and marks the resource as “tainted,” indicating it is in an unknown state and should likely be destroyed and recreated.
  • when: Controls when the provisioner runs.
    • create (default): Runs after resource creation.
    • destroy: Runs before a resource is destroyed. (Requires connection for remote resources).
resource "aws_instance" "app_server" {
  # ... instance configuration ...

  provisioner "remote-exec" {
    inline = ["sudo apt-get update && sudo apt-get install -y nginx"]
    connection { /* ... */ }
    on_failure = fail # If Nginx install fails, taint the instance
  }

  provisioner "remote-exec" {
    when = destroy # This provisioner runs when the instance is being destroyed
    inline = ["sudo systemctl stop nginx && sudo rm -rf /var/www/html/*"]
    connection { /* ... */ }
  }
}

When NOT to Use Terraform Provisioners

As mentioned, provisioners should be used with caution. Here are scenarios where you should strongly consider alternatives:

  • Complex Configuration Management: For tasks like managing user accounts, installing multiple software packages, maintaining service configurations, or ensuring continuous desired state, use a dedicated Configuration Management (CM) tool like Ansible, Chef, Puppet, or SaltStack. These tools are designed for idempotency, error handling, and reporting.
  • Application Deployment: For deploying application code, use specialized Deployment Tools like Jenkins, GitLab CI/CD, Spinnaker, or specific cloud deployment services (e.g., AWS CodeDeploy, Azure DevOps).
  • Initial Server Bootstrapping: For initial setup, Cloud-Init (Linux) or User Data (AWS/Azure) is often a better choice. It is natively supported by cloud providers, runs at boot time, and keeps your Terraform focused purely on infrastructure.
  • Long-Lived Configuration: If your server needs ongoing configuration updates or drifts from its desired state, a CM tool is necessary. Provisioners are “fire and forget.” High Security Requirements: Passing sensitive credentials directly in Terraform files or via provisioners can be risky. Integrations with secret managers are usually more secure.

Terraform Provisioners vs. Ansible / User Data / Providers

  • Provisioners vs. Ansible (or other CM tools):
    • Provisioners: Good for simple, one-off post-provisioning tasks, generally not idempotent by default. Limited state management.
    • Ansible: Excellent for ongoing configuration management, ensuring idempotency, managing complex dependencies, and offering robust error handling. Designed for server configuration, not infrastructure provisioning. Ideally, Terraform provisions the server, then Ansible configures it.
  • Provisioners vs. User Data / Cloud-Init:
    • Provisioners: Run by Terraform after resource creation. More flexible in terms of connection types (SSH/WinRM).
    • User Data/Cloud-Init: Native cloud provider feature. Runs during the instance boot process. Simpler for basic bootstrapping. Often preferred for first-boot configurations.
  • Provisioner vs. Provider:
    • Provider: The core of Terraform. Defines and manages the lifecycle of infrastructure resources (e.g., aws_instance, aws_vpc). It is about what infrastructure exists.
    • Provisioner: A feature of a resource. It is about how to perform actions on or related to that infrastructure after it is created.

Conclusion

Terraform provisioners feature, when used appropriately, can bridge the gap between infrastructure provisioning and initial configuration. However, they are not a substitute for dedicated configuration management or deployment tools.

Mastering provisioners means understanding their capabilities and their limitations. Use them wisely for bootstrapping, cleanup, or simple local tasks, and always consider more robust alternatives for complex, long-lived, or highly critical configuration management.

Author

Debjeet Bhowmik

Experienced Cloud & DevOps Engineer with hands-on experience in AWS, GCP, Terraform, Ansible, ELK, Docker, Git, GitLab, Python, PowerShell, Shell, and theoretical knowledge on Azure, Kubernetes & Jenkins. In my free time, I write blogs on ckdbtech.com

Leave a Comment