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

  1. 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.