
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.
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:
terraform plan won't show you what the command will do.local-exec: Syntax and Core ArgumentsThe 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).
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.
local-exec RisksThe 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:
environment argument, sourcing them from secure locations (like HashiCorp Vault or cloud provider secret managers). Mark sensitive Terraform variables with sensitive = true.local-exec becomes an attack vector. You need solid review processes and secure repository management, which IaC platforms often help enforce.local-execWhile a "last resort," local-exec can be used for:
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.
null_resource and triggersThe 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.
local-execUsing local-exec isn't without its headaches:
terraform plan can't fully predict the outcome of local scripts.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.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.
local-execBefore reaching for local-exec, consider these more declarative or specialized alternatives:
user_data (AWS), custom_data (Azure), or metadata (GCP) with cloud-init to configure instances on first boot.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.
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. |
local-exec Makes Sense, and How to Run It Safelylocal-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.
