TL;DR
Terraform kills the apply run whenever a local-exec command exits with anything other than 0. Two causes account for roughly 90% of cases: the script file isn't where Terraform thinks it is, or the script fails silently and Terraform has no idea why. Quick fixes:
- Use
${path.module}to reference scripts relative to your module, not the working directory. - Add
set -eto your shell scripts so failures are loud, not silent. - Run the command manually first to confirm it works before putting it in a provisioner.
What the Error Actually Means
The full error looks like this:
Error: local-exec provisioner error
Error running command 'bash setup.sh': exit status 1. Output: bash: setup.sh: No such file or directory
Terraform's local-exec provisioner runs commands on the machine executing terraform apply โ not on the remote resource. Any non-zero exit code causes Terraform to treat the entire resource as failed and either roll it back or mark it as tainted.
The bash: setup.sh: No such file or directory part is the real clue. Terraform looks for setup.sh in whatever directory you ran terraform apply from โ which is often not the module directory where your .tf files live.
Root Cause Breakdown
1. Wrong working directory
When you write:
provisioner "local-exec" {
command = "bash setup.sh"
}
Terraform resolves setup.sh relative to wherever you ran terraform apply, not relative to the .tf file. Running apply from a parent directory or a CI pipeline? The script simply won't be found.
2. Script exits with non-zero code
Even with the correct path, any uncaught failure inside the script bubbles up as a non-zero exit. A missing binary, a failed API call, a misconfigured environment variable โ all of these produce exit status 1 or higher. Bash doesn't stop on errors by default, so the failure may not even be obvious from the output.
3. Windows line endings (CRLF)
Scripts created or edited on Windows carry CRLF line endings (\r\n). On Linux, /bin/bash chokes on the extra \r, producing errors like setup.sh: command not found or bad interpreter โ which look nothing like a line-ending problem.
Fix 1: Use ${path.module} for Script Paths
path.module solves the "file not found" variant outright. It always resolves to the directory containing the current .tf file, regardless of where you run terraform apply:
provisioner "local-exec" {
command = "bash ${path.module}/scripts/setup.sh"
}
Alternatively, set the working directory explicitly:
provisioner "local-exec" {
working_dir = "${path.module}/scripts"
command = "bash setup.sh"
}
Both anchor the path to the module โ not wherever your terminal happens to be.
Fix 2: Make the Script Fail Loudly
Add set -euo pipefail at the top of every shell script used in provisioners. The script then exits immediately on any error, giving Terraform a clear non-zero exit code to catch:
#!/usr/bin/env bash
set -euo pipefail
echo "Running setup..."
apt-get install -y some-package
curl -fsSL https://example.com/init | bash
echo "Done."
Without set -e, a failing curl or a missing command gets silently swallowed. The script exits 0, Terraform thinks everything's fine, and you spend an hour wondering why the server isn't configured correctly.
Fix 3: Pass Variables Instead of Hardcoding Paths
Scripts that need runtime values โ a resource IP, an output value, a region โ should receive them as environment variables rather than trying to figure them out internally:
provisioner "local-exec" {
command = "bash ${path.module}/scripts/configure.sh"
environment = {
SERVER_IP = self.private_ip
REGION = var.region
DEPLOY_ENV = var.environment
}
}
Scripts written this way are easy to test locally โ just export those variables and run the script directly, no Terraform needed.
Fix 4: Handle Non-Zero Exits You Actually Expect
Some commands legitimately return non-zero and that's fine. grep returns 1 when there's no match. Wrap those commands so the overall exit code stays 0:
provisioner "local-exec" {
command = "grep -q 'pattern' /etc/config || echo 'Pattern not found, skipping'"
}
Or add a blanket fallback:
provisioner "local-exec" {
command = "bash ${path.module}/scripts/maybe-fails.sh || true"
}
Use || true sparingly โ only when a failure is genuinely acceptable and you're not just hiding a real bug.
Fix 5: Fix CRLF Line Endings
Scripts touched on Windows need converting before they'll run on Linux:
# Using dos2unix
dos2unix scripts/setup.sh
# Or with sed
sed -i 's/\r$//' scripts/setup.sh
# Enforce LF in Git so it never happens again:
echo "*.sh text eol=lf" >> .gitattributes
git add --renormalize .
The Git attribute approach is the permanent fix โ it normalizes line endings for everyone on the team.
Debugging a Failing Provisioner
Always test the command manually before embedding it in a provisioner. Run it from the same directory where you'd invoke terraform apply:
# Simulate what Terraform will run
cd /path/to/your/terraform/root
bash path/to/module/scripts/setup.sh
echo "Exit code: $?"
Fails here? Fix it here โ not inside Terraform.
For verbose Terraform output, set TF_LOG:
TF_LOG=DEBUG terraform apply 2>&1 | grep -A 20 'local-exec'
This prints the exact command Terraform invokes plus the full stdout/stderr, which is often truncated in the standard error display.
Dealing with Tainted Resources
A failed provisioner marks the resource as tainted. On the next apply, Terraform will destroy and recreate it. Fixed the script and want to retry without destroying the resource? Untaint it manually:
# Check what's tainted
terraform state list
terraform show
# Untaint if the resource itself is actually fine
terraform untaint aws_instance.my_server
Verify the Fix Worked
- Run
terraform planโ no unexpected destroy/create cycles should appear. - Run
terraform applyโ the provisioner block should complete cleanly. - Check the output: Terraform prints the provisioner's stdout/stderr inline. Look for your script's success message.
- For extra confidence, append a sentinel string to the command:
provisioner "local-exec" { command = "bash ${path.module}/scripts/setup.sh && echo 'PROVISIONER_OK'" }
Seeing `PROVISIONER_OK` in the Terraform output confirms the script ran to completion.
## When to Avoid local-exec Entirely
Multiple steps, retries, conditionals โ that complexity is a red flag. `local-exec` was never designed for orchestration. At some point the script becomes harder to maintain than the infrastructure itself. Consider these alternatives:
- **null_resource** with triggers for re-running logic only on specific changes.
- **external data source** for read-only scripts that return data back to Terraform.
- **Ansible, Chef, or cloud-init** for complex configuration management โ tools built for exactly this problem.
- **Terraform functions** (`templatefile`, `file`) for generating config files without running scripts at all.

