Fix Terraform 'local-exec provisioner error' When Shell Command Returns Non-Zero Exit Code

intermediate๐Ÿ—๏ธ Terraform2026-05-15| Terraform >= 0.12, Linux/macOS/Windows (WSL), any provider

Error Message

Error: local-exec provisioner error Error running command 'bash setup.sh': exit status 1. Output: bash: setup.sh: No such file or directory
#terraform#provisioner#local-exec#bash#exit-code

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 -e to 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.

Related Error Notes