How to Use Terraform local-exec

Need to trigger local scripts from Terraform? Learn how local-exec works, with step-by-step syntax, examples, and safeguards for reliable automation.

Terraform is renowned for its declarative approach to infrastructure as code. However, sometimes you need to step outside that declarative model to run a local script or command. This is where the local-exec provisioner comes in. But as with any powerful tool, it comes with its own set of complexities and potential pitfalls.

1. Introduction: What is local-exec and Why the Caution?

The local-exec provisioner in Terraform allows you to run a command or script on the machine where Terraform itself is executing, typically after a resource has been created or before it's destroyed. This can be useful for tasks that fall outside the scope of typical Terraform providers, like interacting with a local API, triggering a build script, or preparing the local environment.

However, HashiCorp, the creators of Terraform, explicitly advise that provisioners, including local-exec, should be a last resort. Why? Because they introduce imperative logic into a declarative system. This can lead to:

  • Increased Complexity: Terraform can't fully model the actions of a local script in its execution plan.
  • Uncertainty: Success often depends on the local machine's state (e.g., installed software, network access).
  • Maintenance Overhead: Managing these scripts and their dependencies across different environments can become challenging, especially at scale. Centralized IaC platforms can offer better visibility and control in such scenarios, helping to standardize execution environments.

2. Understanding local-exec: Syntax and Core Arguments

The local-exec provisioner is defined within a resource block:

resource "aws_instance" "example" {
  ami           = "ami-0c55b31ad2c454828" # Example AMI
  instance_type = "t2.micro"

  # ... other configurations ...

  provisioner "local-exec" {
    command = "echo Instance ID: ${self.id} >> instance_ids.txt"
  }
}

In this example, after the aws_instance.example is created, the command will write its ID to a local file.

Key arguments for local-exec:

  • command (Required): The command to execute. It's evaluated in a shell, so be mindful of security (more on this later).
  • interpreter (Optional): Specifies an interpreter (e.g., ["/bin/bash", "-c"]) for the command. Defaults based on the OS.
  • working_dir (Optional): The directory where the command will run.
  • environment (Optional): A map of environment variables to pass to the command. This is the secure way to pass dynamic data.
  • when (Optional): When to run: create (default) or destroy.
  • on_failure (Optional): What to do if the command fails: fail (default, taints the resource on creation) or continue.
  • quiet (Optional): If true, suppresses printing the command itself, but not its output.

The special self object within a provisioner block refers to the parent resource, allowing you to access its attributes (like self.id or self.private_ip).

3. Lifecycle Hooks: local-exec in Action (Creation & Destruction)

Creation-Time Provisioners: By default (when = "create"), local-exec runs after its parent resource is created. If this provisioner fails (and on_failure is fail), Terraform taints the resource. A tainted resource will be destroyed and recreated on the next apply. This behavior underscores the uncertainty: if a local script fails, Terraform assumes the resource might be in an inconsistent state.

Destroy-Time Provisioners: Setting when = "destroy" makes local-exec run before the resource is destroyed. This is useful for cleanup tasks.

resource "aws_instance" "server" {
  # ... configuration ...

  provisioner "local-exec" {
    when    = "destroy"
    command = "echo Server ${self.id} is being destroyed. >> cleanup_log.txt"
  }
}

However, destroy-time provisioners have limitations: they don't run if create_before_destroy is used, if the resource is tainted, or if the resource block (with the provisioner) is entirely removed from the configuration. This makes them somewhat fragile and requires careful planning, especially in complex deployment workflows where an overarching management layer can help ensure consistent execution of such cleanup steps.

4. Security First: Navigating local-exec Risks

The primary security concern with local-exec is command injection. Since the command argument is evaluated in a shell, directly interpolating untrusted variables can be dangerous.

Vulnerable Example (DO NOT USE):

variable "user_input_filename" {
  type = string
}

provisioner "local-exec" {
  # If var.user_input_filename is "; rm -rf /", this is catastrophic
  command = "echo 'data' > ${var.user_input_filename}"
}

Secure Method: Use the environment Argument

variable "user_input_filename" {
  type = string
}

provisioner "local-exec" {
  command     = "/usr/local/bin/safe_script.sh"
  environment = {
    OUTPUT_FILENAME = var.user_input_filename
    SCRIPT_DATA     = "important_info"
  }
}

Your safe_script.sh would then securely access OUTPUT_FILENAME and SCRIPT_DATA as environment variables.

Other security best practices:

  • Principle of Least Privilege: Ensure the Terraform process and its scripts run with minimal necessary permissions.
  • Secret Management: Pass secrets via the environment argument, sourcing them from secure locations (like HashiCorp Vault or cloud provider secret managers). Mark sensitive Terraform variables with sensitive = true.
  • CI/CD Context: Be aware of Poisoned Pipeline Execution (PPE) risks. If an attacker can modify Terraform configurations or scripts, local-exec can become an attack vector. Robust review processes and secure repository management are crucial, often facilitated by IaC platforms that enforce such guardrails.

5. Common Scenarios for local-exec

While a "last resort," local-exec can be used for:

  • Basic script execution: Writing resource attributes to local files.
  • Invoking local CLI tools: Triggering a local build or sending a notification.
  • Capturing command output: local-exec doesn't directly return output to Terraform. A workaround is to redirect output to a file and then use the local_file data source to read it. This is clunky compared to the external data source.

Local health checks: Running a script to poll a new service until it's ready. This can significantly increase apply times.

resource "aws_instance" "web_app" {
  # ... config ...
  user_data = <<-EOF
    #!/bin/bash
    # Install and start a web server
    apt-get update -y && apt-get install -y nginx
    systemctl start nginx
    echo "<h1>Ready!</h1>" > /var/www/html/index.html
  EOF

  provisioner "local-exec" {
    # Assumes wait-for-it.sh is a script that polls the endpoint
    command = "./wait-for-it.sh http://${self.public_ip}:80 --timeout=300"
  }
}

These use cases often add operational overhead and dependencies on the local execution environment, which can be difficult to manage consistently without a standardized platform.

6. Finer Control with null_resource and triggers

The null_resource doesn't create any infrastructure itself but can host provisioners. Combined with its triggers argument, it allows local-exec to run based on arbitrary data changes, not just resource lifecycles.

resource "aws_s3_bucket_object" "deployment_package" {
  bucket = "my-app-bucket"
  key    = "app_v1.zip"
  source = "path/to/app_v1.zip"
  etag   = filemd5("path/to/app_v1.zip") # Changes when file content changes
}

resource "null_resource" "run_local_script_on_package_update" {
  triggers = {
    package_etag = aws_s3_bucket_object.deployment_package.etag
  }

  provisioner "local-exec" {
    command = "echo 'New package deployed with ETag ${self.triggers.package_etag}'. Activating..."
    # In a real scenario, this might call a local script to notify a deployment system
  }
}

Here, the local-exec runs whenever the etag of the S3 object changes. Using timestamp() in triggers (e.g., always_run = timestamp()) forces the provisioner to run on every apply. While flexible, this adds another layer of logic that needs careful tracking, especially in shared or automated environments.

7. The Challenges: Limitations of local-exec

Using local-exec isn't without its headaches:

  • Increased Complexity & Uncertainty: terraform plan can't fully predict the outcome of local scripts.
  • State Management Gaps: Terraform doesn't track changes made by local-exec to your local system. If a script modifies a local file, Terraform won't know if that file is later changed manually. This makes true idempotency reliant solely on perfect script design.
  • Platform-Specificity: Scripts written for Linux may not work on Windows, and vice-versa. This hinders portability.
  • No Guarantee of Resource Operability: A resource might be "created" by Terraform, but its services (e.g., SSH, web server) might not be ready when local-exec runs.

These limitations highlight why relying heavily on local-exec can lead to brittle configurations. Managing these challenges often requires a more sophisticated approach to IaC, where a platform can provide consistency and abstract away some of the underlying execution environment differences.

8. Considering Alternatives to local-exec

Before reaching for local-exec, consider these more declarative or specialized alternatives:

  • VM Bootstrapping: Use user_data (AWS), custom_data (Azure), or metadata (GCP) with cloud-init to configure instances on first boot.
  • Configuration Management Tools: For complex software setup, use Ansible, Chef, Puppet, or SaltStack.
  • Image Baking: Use HashiCorp Packer to create pre-configured machine images.
  • external Data Source: If you need to fetch data from a local script to use within Terraform, the external data source is a better fit. It expects the script to output JSON.
  • terraform_data Resource: This can trigger provisioners based on input changes, similar to null_resource, but is sometimes used for more abstract data-driven provisioning logic.

Choosing the right tool often depends on the specific task. Integrated IaC platforms can simplify the orchestration of these different tools and approaches.

9. local-exec at a Glance: Summary Table

Feature Description
Purpose Execute commands/scripts on the machine running Terraform.
Execution Tied to resource lifecycle (create, destroy) or null_resource triggers.
Key Arguments command, interpreter, environment, when, on_failure.
Pros - Fills gaps where native providers lack functionality.
- Allows interaction with local systems.
Cons - Increases complexity and uncertainty.
- Security risks (command injection).
- State management challenges.
- Platform-specific.
- Can make configurations brittle.
Security Critical: Use environment to pass data, not direct interpolation in command.
Primary Advice Use as a last resort. Prefer declarative alternatives.
Alternatives user_data, config management tools, Packer, external data source.

10. Conclusion: Strategic Use in a Managed IaC Environment

The local-exec provisioner is a powerful, if somewhat blunt, instrument in the Terraform toolkit. It offers a way to perform actions that are otherwise difficult to achieve declaratively. However, its imperative nature, security considerations, and potential for creating complex and brittle configurations mean it should be used sparingly and with caution.

The challenges associated with local-exec – managing dependencies, ensuring security, maintaining portability, and dealing with state inconsistencies – become more pronounced as infrastructure scales. This is where comprehensive IaC management platforms like Scalr can provide significant value. By offering features such as custom hooks in a more controlled environment, RBAC, policy enforcement, and consistent execution environments, they can help mitigate the risks and operational overhead associated with less predictable elements like local-exec. This allows teams to leverage such tools strategically when absolutely necessary, without compromising the overall stability, security, and scalability of their infrastructure automation.