Mastering Terraform's local-exec provisioner: a practical guide
Terraform's local-exec provisioner executes commands on the machine running Terraform—not on remote resources—creating a bridge between infrastructure-as-code and imperative operations. While HashiCorp recommends using it as a last resort, understanding when and how to implement it properly unlocks powerful workflows for DevOps practitioners.
What is local-exec and when it belongs in your toolbox
The local-exec provisioner runs commands on the machine executing Terraform after a resource is created. Unlike remote-exec (which runs on the provisioned resource), local-exec operates entirely within your local environment.
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
provisioner "local-exec" {
command = "echo ${self.private_ip} >> private_ips.txt"
}
}
When to use local-exec:
- Integrating with external systems after resource creation
- Updating local configuration files with resource outputs
- Executing local scripts that interact with newly created infrastructure
- Performing health checks or validation against deployed resources
- Generating dynamic inventory files for configuration management tools
- Recording deployment information for documentation
When to avoid local-exec:
- For operations already supported by Terraform providers
- When configuring remote resources (use remote-exec instead)
- For long-running or complex operations better handled by dedicated tools
- For operations that should be tracked in Terraform state
- When you need guaranteed idempotent behavior
Syntax and configuration options decoded
The local-exec provisioner offers several configuration options beyond the basic command parameter:
provisioner "local-exec" {
command = "echo 'Hello World'" # Required: command to execute
working_dir = "/path/to/directory" # Optional: working directory
interpreter = ["/bin/bash", "-c"] # Optional: command interpreter
environment = { # Optional: environment variables
NAME = "value"
}
when = "create" # Optional: when to run (create/destroy)
quiet = false # Optional: suppress command output in logs
on_failure = "fail" # Optional: behavior on failure (fail/continue)
}
Each parameter serves a specific purpose:
- command: The only required parameter; specifies what to execute
- working_dir: Sets the directory where the command runs
- interpreter: Defines the program and arguments used to execute the command
- environment: Specifies environment variables available during execution
- when: Determines if the provisioner runs after creation or before destruction
- quiet: Controls whether the command itself is displayed in Terraform output
- on_failure: Determines whether Terraform should continue or fail if the command fails
Real-world use cases with practical examples
Logging deployment information
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
provisioner "local-exec" {
command = "echo 'Instance ${self.id} created with IP ${self.private_ip}' >> deployment.log"
}
}
Generating Ansible inventory files
resource "aws_instance" "web_servers" {
count = var.web_server_count
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = {
Name = "web-server-${count.index}"
}
}
resource "local_file" "ansible_inventory" {
content = templatefile("${path.module}/templates/inventory.tpl", {
web_servers = aws_instance.web_servers.*.public_ip
})
filename = "${path.module}/inventory"
provisioner "local-exec" {
command = "chmod 600 ${path.module}/inventory"
}
}
Running database migrations
resource "aws_db_instance" "app_db" {
# Database configuration...
}
resource "null_resource" "db_migrations" {
depends_on = [aws_db_instance.app_db]
provisioner "local-exec" {
working_dir = "${path.module}/db"
environment = {
DB_HOST = aws_db_instance.app_db.address
DB_PORT = aws_db_instance.app_db.port
DB_NAME = aws_db_instance.app_db.name
DB_USER = var.db_username
DB_PASSWORD = var.db_password
}
command = "psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f migrations.sql"
}
}
Destroy-time operations
resource "aws_instance" "registered_node" {
ami = data.aws_ami.rhel.id
instance_type = "t3.small"
# Register with external system during creation
provisioner "local-exec" {
command = "register-node.sh --node-ip=${self.private_ip} --node-id=${self.id}"
}
# Deregister when destroying the resource
provisioner "local-exec" {
when = destroy
command = "deregister-node.sh --node-id=${self.id}"
}
}
Health checks for resources
resource "aws_instance" "app_server" {
ami = data.aws_ami.app.id
instance_type = "t3.medium"
user_data = file("${path.module}/startup.sh")
provisioner "local-exec" {
command = "${path.module}/scripts/wait-for-endpoint.sh http://${self.public_ip}:8080/health -t 300"
}
}
Best practices for sustainable implementation
Security best practices
- Use short-lived credentials when possible
- Run commands with minimal required permissions
Mark sensitive variables appropriately
variable "api_key" {
type = string
sensitive = true
description = "API key for external service"
}
Use environment variables instead of command interpolation
# Not recommended - potential injection risk
provisioner "local-exec" {
command = "setup_app.sh --db-password=${var.db_password}"
}
# Recommended approach
provisioner "local-exec" {
environment = {
DB_PASSWORD = var.db_password
}
command = "setup_app.sh --db-password=$DB_PASSWORD"
}
Maintainability and readability
- Document your provisioners with clear comments
- Use working_dir for better script organization
- Prefer modular, focused commands over monolithic ones
Use external script files for complex operations
provisioner "local-exec" {
command = "${path.module}/scripts/complex_deployment.sh"
}
Error handling and resilience
Add error handling within scripts
provisioner "local-exec" {
command = <<EOT
set -e # Exit immediately if a command exits with non-zero status
./deploy_step1.sh
./deploy_step2.sh
./deploy_step3.sh
EOT
}
Use on_failure parameter to control failure behavior
provisioner "local-exec" {
command = "deploy_app.sh"
on_failure = "continue" # Options: "continue" or "fail" (default)
}
Key differences between local-exec and remote-exec
Feature | local-exec | remote-exec |
---|---|---|
Execution Location | Runs on the machine executing Terraform | Runs on the remote resource being provisioned |
Connection Requirements | None - runs locally | Requires a connection block with credentials (SSH/WinRM) |
Command Format | Single command string | List of commands (inline) or path to script |
Typical Use Cases | Local system integration, data collection | Remote resource configuration, software installation |
Dependencies | Only requires local software | Requires network access and authentication to the resource |
Execution Timing | Executes immediately when resource is created | Waits until it can connect to the resource |
Security Context | Runs with permissions of the Terraform process | Runs with permissions of the user specified in the connection |
Example comparison:
# local-exec: runs on the Terraform machine
provisioner "local-exec" {
command = "echo 'Executed locally'"
}
# remote-exec: runs on the provisioned resource
provisioner "remote-exec" {
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
}
inline = [
"echo 'Executed remotely'"
]
}
Error handling and troubleshooting techniques
Common error patterns
- Exit code handling: By default, non-zero exit codes cause Terraform to mark the resource as tainted
- Error propagation: Failures typically stop the Terraform run unless configured otherwise
Troubleshooting strategies
- Test commands independently outside Terraform to verify behavior
Implement timeouts for long-running commands
provisioner "local-exec" {
command = "timeout 300 your_long_running_command" # Linux
# or for Windows:
# command = "powershell -Command \"Start-Process your_command -Wait -TimeoutSec 300\""
}
Add debugging output to your commands
provisioner "local-exec" {
command = "set -x && your_command" # Shows each command as it executes
}
Enable verbose logging
export TF_LOG=DEBUG
terraform apply
Robust error handling in scripts
provisioner "local-exec" {
command = <<-EOT
#!/bin/bash
set -e
function cleanup {
echo "Cleaning up resources..." >> cleanup.log
# Cleanup operations here
}
trap cleanup EXIT
echo "Starting operation..."
command_that_might_fail || {
echo "Command failed with exit code $?" >> error.log
exit 1
}
EOT
}
Platform-specific considerations for Windows vs Linux/Mac
Interpreter differences
# Windows-specific interpreter
provisioner "local-exec" {
interpreter = ["PowerShell", "-Command"]
command = "Write-Host 'Hello from PowerShell'"
}
# Bash-specific interpreter
provisioner "local-exec" {
interpreter = ["/bin/bash", "-c"]
command = "echo 'Hello from Bash'"
}
Path handling
- Windows uses backslashes, which require escaping in HCL
- Use forward slashes in Windows paths or double backslashes
# Works cross-platform
provisioner "local-exec" {
working_dir = "${path.module}/scripts"
command = "some-script.sh"
}
# Windows-specific with proper escaping
provisioner "local-exec" {
working_dir = "C:\\Scripts\\Terraform"
command = "some-script.bat"
}
Cross-platform compatibility
locals {
is_windows = substr(pathexpand("~"), 0, 1) == "/" ? false : true
# Choose appropriate command based on OS
sleep_command = local.is_windows ? "timeout /t 30" : "sleep 30"
}
resource "null_resource" "example" {
provisioner "local-exec" {
interpreter = local.is_windows ? ["PowerShell", "-Command"] : []
command = local.sleep_command
}
}
Security considerations when using local-exec
Shell injection vulnerabilities
The most significant security risk with local-exec is shell injection, where malicious input could execute unintended commands:
# VULNERABLE to injection if var.user_input contains shell metacharacters
provisioner "local-exec" {
command = "some-command --param ${var.user_input}"
}
# SAFER approach
provisioner "local-exec" {
command = "some-command --param $USER_INPUT"
environment = {
USER_INPUT = var.user_input
}
}
Handling sensitive data
# Fetching secrets from a secure source
data "aws_secretsmanager_secret_version" "db_creds" {
secret_id = "db/credentials"
}
locals {
db_creds = jsondecode(data.aws_secretsmanager_secret_version.db_creds.secret_string)
}
resource "null_resource" "db_setup" {
provisioner "local-exec" {
command = "setup-database"
environment = {
DB_USER = local.db_creds.username
DB_PASS = local.db_creds.password
}
}
}
CI/CD pipeline security
- Implement code reviews for files containing local-exec
- Use static analysis tools like tfsec, Checkov, or Terrascan
- Run Terraform in isolated environments with minimal permissions
- Use short-lived, scoped credentials for CI/CD pipelines
Alternatives to local-exec for cleaner implementations
Provider-native resources
# Instead of using local-exec to create DNS entries
resource "aws_instance" "example" {
# ...
provisioner "local-exec" {
command = "aws route53 change-resource-record-sets ..."
}
}
# Use the provider's native resource
resource "aws_route53_record" "example" {
zone_id = aws_route53_zone.example.zone_id
name = "example.com"
type = "A"
ttl = 300
records = [aws_instance.example.public_ip]
}
User data / cloud-init for instance configuration
resource "aws_instance" "example" {
ami = "ami-12345678"
instance_type = "t2.micro"
user_data = <<-EOF
#!/bin/bash
apt-get update
apt-get install -y nginx
systemctl enable nginx
systemctl start nginx
EOF
}
External data sources
data "external" "example" {
program = ["bash", "-c", "echo '{\"result\": \"value\"}'"]
}
# Use the output
resource "aws_ssm_parameter" "example" {
name = "/example/parameter"
type = "String"
value = data.external.example.result.result
}
Configuration management tools
For complex configuration, use dedicated tools like Ansible, Chef, or Puppet, integrated with Terraform as a separate step.
Integration with other Terraform features
Modules integration
# modules/app_deployment/main.tf
resource "aws_instance" "app" {
ami = var.ami_id
instance_type = var.instance_type
provisioner "local-exec" {
working_dir = var.scripts_path
environment = {
APP_NAME = var.app_name
ENVIRONMENT = var.environment
SERVER_IP = self.private_ip
}
command = "./deploy.sh"
}
}
# Root module
module "web_app" {
source = "./modules/app_deployment"
ami_id = data.aws_ami.app.id
instance_type = "t3.medium"
app_name = "web-frontend"
environment = "production"
scripts_path = "${path.module}/deployment_scripts"
}
Conditional execution with count and for_each
resource "null_resource" "conditional_action" {
count = var.run_post_deploy_scripts ? 1 : 0
provisioner "local-exec" {
command = "echo 'Running post-deployment scripts' && ./post_deploy.sh"
}
}
resource "null_resource" "foreach_exec" {
for_each = var.environments
provisioner "local-exec" {
environment = {
ENV_NAME = each.key
ENV_TYPE = each.value.type
}
command = "./configure_env.sh"
}
}
Using triggers for forced execution
resource "null_resource" "always_run" {
triggers = {
always_run = timestamp()
}
provisioner "local-exec" {
command = "./run_always.sh"
}
}
Using output values with local-exec
resource "aws_instance" "cluster" {
count = var.cluster_size
ami = data.aws_ami.cluster_ami.id
# ...other configuration...
}
resource "null_resource" "document_cluster" {
depends_on = [aws_instance.cluster]
provisioner "local-exec" {
command = "echo 'Cluster nodes: ${join(",", aws_instance.cluster.*.private_ip)}' > cluster_info.txt"
}
}
output "cluster_ips" {
value = aws_instance.cluster.*.private_ip
}
Conclusion
Terraform's local-exec provisioner offers a powerful bridge between declarative infrastructure definition and imperative operations. While HashiCorp recommends using it sparingly, understanding how to implement it correctly opens up valuable workflows for complex deployment scenarios.
For optimal results:
- Follow security best practices, especially around variable handling
- Prefer native provider resources when available
- Create platform-independent scripts when possible
- Implement proper error handling and retries
- Keep commands focused and well-documented
By following these guidelines, you can effectively leverage local-exec while maintaining the integrity and security of your infrastructure as code.