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) ordestroy
.on_failure
(Optional): What to do if the command fails:fail
(default, taints the resource on creation) orcontinue
.quiet
(Optional): Iftrue
, 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 withsensitive = 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 thelocal_file
data source to read it. This is clunky compared to theexternal
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), ormetadata
(GCP) withcloud-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, theexternal
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 tonull_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.