TrademarkTrademark
Features
Documentation

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.
Sebastian StadilMarch 3, 2026Updated March 31, 2026
How to Use Terraform local-exec
Key takeaways
  • The local-exec provisioner runs a command or script on the machine executing Terraform, typically after a resource is created or before it is destroyed.
  • HashiCorp advises using provisioners only as a last resort because they introduce imperative logic Terraform cannot model in its plan, adding complexity and uncertainty.
  • To avoid command injection, pass dynamic data through the environment argument rather than interpolating untrusted variables directly into the command string.
  • A creation-time local-exec failure taints the resource so it is destroyed and recreated on the next apply, and destroy-time provisioners are skipped when create_before_destroy is set or the resource is tainted.
  • Declarative alternatives such as cloud-init user_data, Packer images, configuration management tools, and the external data source are usually preferable to local-exec.

This article is part of our Terraform Provisioners guide.

Terraform is declarative, but sometimes you need to run a plain shell command as part of an apply. Maybe you're kicking off a build script, or calling a local API that has no Terraform provider. The local-exec provisioner handles cases like that. It's easy to overuse, though, so this guide covers how it works and the trouble it causes when you lean on it too hard.

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

The local-exec provisioner lets you run a command or script on the machine where Terraform itself is running, usually after a resource has been created or before it's destroyed. It's handy for tasks that fall outside what normal Terraform providers cover, like hitting a local API, triggering a build script, or setting up the local environment.

But HashiCorp, the people who make Terraform, say plainly that provisioners, local-exec included, should be a last resort. Why? Because they put imperative logic into a declarative system. That can lead to:

  • Increased complexity: Terraform can't fully model the actions of a local script in its execution plan, so terraform plan won't show you what the command will do.
  • Uncertainty: Whether the command succeeds often depends on the local machine's state, such as installed software or network access.
  • Maintenance overhead: Keeping these scripts and their dependencies working across different environments gets hard, especially at scale. Centralized IaC platforms can give you better visibility and control here, and help 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 gets destroyed and recreated on the next apply. This is the uncertainty in action: 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"
  }
}

Destroy-time provisioners come with limits, though: they don't run if create_before_destroy is used, if the resource is tainted, or if you remove the resource block (with the provisioner) from the configuration entirely. That makes them fragile and means you have to plan carefully, especially in complex deployment workflows where a management layer on top can help run cleanup steps consistently.

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: Watch out for Poisoned Pipeline Execution (PPE) risks. If an attacker can change Terraform configurations or scripts, local-exec becomes an attack vector. You need solid review processes and secure repository management, which IaC platforms often help enforce.

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 tend to add operational overhead and tie you to the local execution environment, which is hard 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. Putting timestamp() in triggers (e.g., always_run = timestamp()) forces the provisioner to run on every apply. It's flexible, but it adds another layer of logic you have to track, 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 limits are why leaning hard on local-exec tends to give you brittle configurations. Dealing with them usually calls for a more careful approach to IaC, where a platform can keep things consistent and hide some of the differences between execution environments.

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.

The right tool usually depends on the task at hand. Integrated IaC platforms can make it easier to orchestrate these different tools and approaches.

9. local-exec at a Glance: Summary Table

Feature Description
Purpose Execute commands or 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 fall short; lets you interact with local systems.
Cons Adds complexity and uncertainty; risks command injection; gaps in state management; platform-specific; can make configurations brittle.
Security Pass data through environment, never by interpolating it into command.
Primary advice Use as a last resort; prefer declarative alternatives.
Alternatives user_data, config management tools, Packer, the external data source.

10. When local-exec Makes Sense, and How to Run It Safely

local-exec solves real problems, but it works against the grain of Terraform. Because it runs imperative scripts, Terraform can't plan around it or detect drift in what it changes, and the configurations that depend on it tend to be fragile. Use it when you have to, and reach for a declarative option whenever there is one.

These problems get worse the more you scale. A managed platform helps here: Scalr runs provisioners through controlled hooks, applies RBAC and policy checks, and gives every run the same environment, so it's safer in the cases where you still need local-exec.

If you are evaluating that kind of platform, Scalr's pricing is public: free up to 50 runs per month, then per-run billing with no per-user charges.

About the author
Sebastian StadilCEO at Scalr
Sebastian Stadil is the CEO of Scalr with 15+ years of DevOps experience. He started with AWS in 2004 and advised early Microsoft Azure and Google Cloud.