Quick answer: Automating Terraform with scripts means wrapping the init β fmt β validate β plan β apply sequence in a shell script, Makefile or CI pipeline so every run is identical, non-interactive and logged. The key ingredients are the -input=false and -auto-approve flags, saved plan files, environment-specific variable files, and strict error handling so a failed step stops the run instead of silently continuing.
Typing the same five commands by hand works for a demo and falls apart the moment two people share an environment. In this guide you will build a production-quality Bash wrapper, a Makefile with per-environment targets, and see how the same script becomes a CI/CD job β along with the pitfalls that catch most teams when they first automate Terraform.
Why script Terraform at all?
Terraform is already declarative, so why add another layer? Because the commands around Terraform are where mistakes happen:
- Someone forgets
terraform initafter a provider change and gets a confusing error. - Someone applies against production with the dev
.tfvarsfile. - Someone approves a plan, gets distracted, and applies a different plan twenty minutes later.
- Formatting and validation are skipped “just this once”.
A script encodes the correct sequence once. Everyone β including your CI runner β then executes the same path. The sections below move from the simplest wrapper to a full pipeline. If you need a refresher on the commands themselves, read Terraform Core Concepts and Syntax: A Beginner’s Guide first.
Make Terraform non-interactive
Before writing any script, know the flags that stop Terraform from waiting for a human:
| Flag or variable | Effect | When to use |
|---|---|---|
-input=false |
Never prompt for missing variables; fail instead | Every scripted command |
-auto-approve |
Skip the yes/no confirmation on apply or destroy | Only when applying a saved, reviewed plan |
-out=tfplan |
Save the plan to a file | Always; apply the file, not a fresh plan |
-detailed-exitcode |
Exit 0 = no changes, 1 = error, 2 = changes pending | Drift checks and conditional applies |
TF_IN_AUTOMATION=1 |
Suppresses hints aimed at interactive users | CI environments |
TF_VAR_name |
Supplies a variable via the environment | Passing secrets from CI without files |
-lock-timeout=5m |
Wait for a state lock rather than failing instantly | Shared environments with concurrent runs |
A production-ready Bash wrapper
This script takes an environment name and an action, selects the right variable and backend files, and refuses to continue if any step fails. Save it as tf.sh and make it executable with chmod +x tf.sh.
#!/usr/bin/env bash
# Usage: ./tf.sh <env> <plan|apply|destroy>
set -euo pipefail
ENV="${1:?environment required (dev|staging|prod)}"
ACTION="${2:?action required (plan|apply|destroy)}"
DIR="$(cd "$(dirname "$0")" && pwd)"
VAR_FILE="$DIR/envs/$ENV.tfvars"
BACKEND_FILE="$DIR/envs/$ENV.backend.hcl"
PLAN_FILE="$DIR/.plans/$ENV.tfplan"
export TF_IN_AUTOMATION=1
mkdir -p "$DIR/.plans"
[[ -f "$VAR_FILE" ]] || { echo "Missing $VAR_FILE" >&2; exit 1; }
[[ -f "$BACKEND_FILE" ]] || { echo "Missing $BACKEND_FILE" >&2; exit 1; }
terraform -chdir="$DIR" init -input=false -reconfigure \
-backend-config="$BACKEND_FILE"
terraform -chdir="$DIR" fmt -check -recursive
terraform -chdir="$DIR" validate
case "$ACTION" in
plan)
terraform -chdir="$DIR" plan -input=false -lock-timeout=5m \
-var-file="$VAR_FILE" -out="$PLAN_FILE"
;;
apply)
[[ -f "$PLAN_FILE" ]] || { echo "Run plan first" >&2; exit 1; }
terraform -chdir="$DIR" apply -input=false -lock-timeout=5m "$PLAN_FILE"
rm -f "$PLAN_FILE"
;;
destroy)
[[ "$ENV" == "prod" ]] && { echo "Refusing to destroy prod from a script" >&2; exit 1; }
terraform -chdir="$DIR" destroy -input=false -var-file="$VAR_FILE" -auto-approve
;;
*)
echo "Unknown action: $ACTION" >&2; exit 1 ;;
esac
Things worth noticing:
set -euo pipefailstops the script on the first error, on unset variables, and on failures inside pipes. Without it, a failedvalidatewould be followed by anapply.- Backend settings live in
envs/<env>.backend.hcland are passed with-backend-config, so one codebase can target different state files per environment. applyonly ever consumes a saved plan. Deleting the plan after a successful apply prevents the same plan being applied twice.- Production destroy is blocked outright. Deliberate friction around destructive actions is a feature.
Environment-specific values belong in the .tfvars files; see Using Variables and Expressions in Terraform for how to structure them.
A Makefile for discoverable commands
Makefiles are popular because make plan ENV=dev is easy to remember and tab-complete. The Makefile can call the wrapper above or run Terraform directly:
ENV ?= dev
TF := terraform -chdir=infra
VAR := -var-file=envs/$(ENV).tfvars
.PHONY: init fmt validate plan apply destroy lint
init:
$(TF) init -input=false -backend-config=envs/$(ENV).backend.hcl
fmt:
$(TF) fmt -recursive
validate: init
$(TF) validate
lint: validate
tflint --chdir=infra
trivy config infra
plan: lint
$(TF) plan -input=false $(VAR) -out=.plans/$(ENV).tfplan
apply:
$(TF) apply -input=false .plans/$(ENV).tfplan
destroy:
@[ "$(ENV)" != "prod" ] || (echo "refusing to destroy prod"; exit 1)
$(TF) destroy -input=false $(VAR)
Make’s dependency chain (plan needs lint, which needs validate, which needs init) means a developer cannot skip steps by accident. Remember that Makefile recipes must be indented with a real tab character, not spaces.
From scripts to CI/CD
Once the script exists, a pipeline is just the same script run by a machine on every pull request and merge. In GitHub Actions the pattern is: on pull request, run ./tf.sh staging plan and post the plan as a comment; on merge to main, run plan then apply in a protected environment that requires a reviewer’s approval. GitLab CI, Jenkins and AWS CodePipeline follow identical logic with different YAML. Authenticate the runner with OIDC rather than stored access keys, and store the plan as a pipeline artifact so the apply job uses exactly what was reviewed.
Tools such as Atlantis, HCP Terraform, Spacelift and env0 take this further by running plan on every PR automatically and applying on comment or merge β useful once you have more than a handful of root modules.
Seven automation mistakes to avoid
- Using
-auto-approveon a fresh plan. You approve nothing and apply whatever the world looks like now. Always apply a saved plan file. - No
set -ein Bash. A failed validate is followed by a successful-looking apply of broken code. - Hard-coding the environment. Scripts that only know about
devget copied and edited forprod, and the copies drift apart. - Printing secrets.
set -xand echo statements leakTF_VAR_db_passwordinto CI logs. - Running concurrent applies against one state. Rely on state locking and serialise pipeline jobs per environment.
- Skipping
initin the script. Provider or module changes then fail at plan with confusing errors. - Scripting
destroywithout guard rails. Require an explicit confirmation variable or block it for production entirely.
Frequently asked questions
Should I use Bash, Python or Make to wrap Terraform?
Bash and Make cover 90 percent of needs and are available on every CI runner. Reach for Python (for example with the python-terraform library or subprocess) only when you need complex logic such as generating variable files from an inventory.
Is Terragrunt a replacement for scripts?
Terragrunt is a thin wrapper that handles backend configuration, dependencies between modules and keeping code DRY across environments. It solves many of the same problems as a custom script with less code, and is worth evaluating once you manage several environments.
How do I pass secrets to a scripted run?
Export them as TF_VAR_<name> environment variables from your CI secret store, or fetch them inside Terraform from a secrets manager data source. Never write them into .tfvars files that get committed.
Can a script detect drift automatically?
Yes. Run terraform plan -detailed-exitcode on a schedule; exit code 2 means the live infrastructure differs from code, and your script can send an alert.
Key takeaways
- Wrap
init,fmt,validate,planandapplyin one script so every run follows the same path. - Use
-input=false, saved plan files andTF_IN_AUTOMATIONto make runs non-interactive and reproducible. - Parameterise by environment with separate
.tfvarsand backend files, never by copying scripts. - Fail fast with
set -euo pipefailand guard destructive actions. - The same script becomes your CI/CD job; add OIDC authentication and plan artifacts.
Want to build these pipelines end to end on real cloud accounts? Our DevOps course covers Terraform automation, Bash scripting and CI/CD with live projects, mentor support and placement assistance. For step-by-step demos, subscribe to our YouTube channel.


