Build a Terraform Validation Pipeline

Write a Bash script that validates Terraform configs with color-coded results.

Introduction

30 Second Summary

One bad infrastructure deploy can take down an entire production environment. The difference between a safe deploy and a disaster often comes down to whether someone remembered to run a few checks first.

In this project, you will build a Bash script that validates Terraform configurations before deployment. It catches formatting issues, syntax errors, and dangerous planned changes in seconds.

What You'll Build

You will run a single command in your terminal and instantly see color-coded pass/fail results telling you whether your Azure Terraform configuration is safe to deploy.

By the end of this project, you'll have:

  • A validation pipeline you trigger with one command (./validate.sh) that reports formatting, syntax, and policy check results with clear colored output.
  • A policy enforcement check that catches dangerous operations like unintended resource deletions by parsing Terraform plan JSON output with jq.
  • Practical Bash scripting skills including variables, functions, conditionals, exit codes, and colored terminal output, learned by building a real DevOps tool you can drop into any Terraform project.
  • Secret Mission: Add command-line argument parsing so the script accepts flags like --strict and --skip-plan to customize its behavior.

Are there any prerequisites?

This project assumes you have basic-to-intermediate Terraform knowledge and an Azure account with the Azure CLI already authenticated. You should also have jq installed on your machine.

No Azure resources are created during this project. You will only run terraform plan, so there are no cloud costs.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. You're creating a Bash script that automates Terraform validation checks for Azure infrastructure configurations, replacing the manual process of running commands one by one before deploying.

Set Up the Project Structure

Before you can write a validation script, you need Terraform configurations to validate against. A script without real configs to test is like a spell-checker with no document.

In this step, you will create a small Azure project that defines a resource group and virtual network. This gives your validation script something realistic to check in the later steps.

In this step, get ready to:
  • Create Terraform configuration files for an Azure resource group and virtual network.
  • Initialize the Terraform project with the azurerm provider.
  • Verify the configuration is valid with terraform validate.
Create the project directory and provider configuration

Every Terraform project lives in its own directory. You will create a folder on your Desktop and set up the provider configuration that tells Terraform to use Azure.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir terraform-validator
cd terraform-validator
  • Confirm you are in the new directory by running:
pwd

You should see /Users/<your-username>/Desktop/terraform-validator in the output.

Now you will create main.tf, the primary configuration file. It holds the provider setup and resource definitions.

  • Create main.tf and add the following provider configuration:
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }

  required_version = ">= 1.0"
}

provider "azurerm" {
  features {}
  resource_provider_registrations = "none"
}

What does this code do?

The terraform block pins the azurerm provider to version ~> 4.0 and requires Terraform 1.0+ to run.

The provider block configures Azure CLI authentication (your existing az login session). Setting resource_provider_registrations = "none" prevents the provider from trying to auto-register Azure Resource Providers, which avoids permission issues.

  • Now add the Azure resource definitions below the provider block in the same main.tf file:
resource "azurerm_resource_group" "example" {
  name     = var.resource_group_name
  location = var.location

  tags = {
    environment = var.environment
    managed_by  = "terraform"
  }
}

resource "azurerm_virtual_network" "example" {
  name                = "${var.project_name}-vnet"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name

  tags = {
    environment = var.environment
    managed_by  = "terraform"
  }
}

What do these resources define?

  • azurerm_resource_group creates a logical container for Azure resources. It uses variables for the name and location so you can change them without editing this file.
  • azurerm_virtual_network creates a network inside that resource group. It references the resource group's location and name directly, keeping everything consistent.
  • Save main.tf.
  • Confirm the file exists by running:
ls

You should see main.tf listed in the output.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf with both the provider block and the two resource blocks.

ⓧ I'd like to double check the full code

Your main.tf should contain two sections that you added above, in this order:

  • The terraform block with required_providers and required_version, followed by the provider "azurerm" block.
  • The azurerm_resource_group and azurerm_virtual_network resource blocks.

Scroll up and confirm both code blocks are present in your file, in order.

File not showing up?

  • Make sure you saved the file inside the terraform-validator directory, not on your Desktop.
  • Confirm your terminal is in the right directory with pwd.

help me find my main.tf file in the terminal

Add variable and output definitions

The resources in main.tf reference variables like var.resource_group_name and var.location. You need to define those variables and add outputs that expose resource IDs.

  • Create variables.tf with the following variable definitions:
variable "resource_group_name" {
  description = "Name of the Azure resource group"
  type        = string
  default     = "rg-terraform-validator"
}

variable "location" {
  description = "Azure region for resources"
  type        = string
  default     = "australiaeast"
}

variable "environment" {
  description = "Environment tag for resources"
  type        = string
  default     = "dev"
}

variable "project_name" {
  description = "Project name used for resource naming"
  type        = string
  default     = "tfvalidator"
}

What does this code do?

Each variable block declares an input with a type, description, and default value. These defaults mean you can run terraform plan without manually providing values every time.

  • Save variables.tf.
  • Create outputs.tf with the following output definitions:
output "resource_group_id" {
  description = "The ID of the resource group"
  value       = azurerm_resource_group.example.id
}

output "vnet_id" {
  description = "The ID of the virtual network"
  value       = azurerm_virtual_network.example.id
}

What does this code do?

Output blocks expose values after Terraform runs. These will print the resource group and virtual network IDs, which are useful for debugging and for other Terraform modules that reference these resources.

  • Save outputs.tf.
  • Confirm all three files exist by running:
ls

You should see main.tf, outputs.tf, and variables.tf in the output.

Missing a file?

  • Check that you saved each file in the terraform-validator directory (not a parent or subfolder).
  • File names must be exact, including the .tf extension. Double check for typos like variable.tf (missing the s).

help me find my missing Terraform files

Initialize and validate the Terraform project

Terraform needs to download the azurerm provider plugin before it can understand your configuration. The terraform init command handles this based on the required_providers block you defined.

  • Initialize the project by running:
terraform init

This downloads the azurerm provider and creates a .terraform directory along with a .terraform.lock.hcl file that locks the provider version.

You should see Terraform has been successfully initialized! in the output.

Seeing an error during init?

  • If you see a network error, check your internet connection. Terraform needs to download the provider from the registry.
  • If you see a version constraint error, confirm the version = "~> 4.0" line in your main.tf matches exactly.
  • If you see command not found, confirm Terraform is installed by running terraform --version.

help me fix my terraform init error

Now that the provider is downloaded, you can check whether your configuration files have any syntax errors.

  • Before you run the next command, what do you think will happen? Do you expect this configuration to be valid or will there be an error?
  • Validate the configuration by running:
terraform validate

You should see:

Success! The configuration is valid.

This confirms all three files have correct syntax, valid references between resources, and properly declared variables.

Seeing validation errors?

  • Check that variables.tf declares all four variables referenced in main.tf: resource_group_name, location, environment, and project_name.
  • Look for typos in resource references. For example, azurerm_resource_group.example.location must match the resource name "example" exactly.
  • Confirm you ran terraform init first. Validation requires an initialized working directory.

help me fix my terraform validate errors

Your Terraform project is set up and validated. Next up, you will write a Bash script that automates these validation checks so you never have to run them manually again.

Write Your First Bash Script with Formatting Checks

Your Terraform project is initialized and validated. Now you have a new problem: every time you make changes, you need to manually run terraform fmt -check to catch formatting inconsistencies.

Forgetting that check means sloppy, inconsistent code sneaks through. In this step, you will wrap that check inside a Bash script so one command handles everything for you.

In this step, get ready to:
  • Create a Bash script file with the proper shebang line and make it executable.
  • Add color variables and helper functions for clear terminal output.
  • Build a formatting check function that reports PASS or FAIL.
Create the script file with color output helpers

Every Bash script starts with a shebang line. This tells your operating system which interpreter to use when running the file.

  • Create a new file called validate.sh in your terraform-validator folder and add this code:
#!/bin/bash

# Terraform Validation Pipeline
# Runs formatting, syntax, and policy checks against Terraform configurations

# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
RESET='\033[0m'

print_pass() {
  echo -e "  ${GREEN}PASS${RESET}: $1"
}

print_fail() {
  echo -e "  ${RED}FAIL${RESET}: $1"
}

What does this code do?

The first line #!/bin/bash is the shebang. It tells the system to run this file using the Bash interpreter, regardless of which shell you normally use.

The ANSI escape codes (like \033[0;31m) tell the terminal to change text color. Storing them in variables (RED, GREEN) makes the code readable instead of scattering cryptic codes everywhere.

The print_pass and print_fail helper functions take a message as $1 (the first argument) and print it with the right color prefix. The echo -e flag enables interpretation of escape sequences so the color codes actually work.

  • Save validate.sh.
  • Make the script executable by running this command:
chmod +x validate.sh

Why do I need chmod +x?

By default, new files don't have execute permission. The chmod +x command adds that permission so you can run the script directly with ./validate.sh instead of typing bash validate.sh every time.

  • Confirm the file exists and is executable by running:
ls -l validate.sh

You should see -rwxr-xr-x (or similar) at the start of the output. The x characters confirm execute permission is set.

Don't see the x permission?

Make sure you ran chmod +x validate.sh (not chmod x validate.sh without the plus sign). Run it again and check with ls -l.

Help me debug this

Add the formatting check function

The helper functions are ready to print colored results. Now you need a function that actually runs the check and decides whether to print PASS or FAIL.

  • Open validate.sh and add this code below the print_fail function:
check_formatting() {
  echo -e "${BLUE}[1/3] Checking formatting...${RESET}"

  if terraform fmt -check -recursive > /dev/null 2>&1; then
    print_pass "Formatting check passed"
    return 0
  else
    local unformatted
    unformatted=$(terraform fmt -check -recursive 2>/dev/null)
    print_fail "Formatting check failed"
    echo "       Files needing formatting:"
    echo "$unformatted" | while IFS= read -r file; do
      echo "         - $file"
    done
    return 1
  fi
}

check_formatting

What does this code do?

The check_formatting function runs terraform fmt -check -recursive inside an if statement. In Bash, if checks the exit code of the command that follows it. Exit code 0 means success (all files are formatted). Any non-zero exit code means files need formatting.

The -check flag checks formatting without modifying files. The -recursive flag processes files in subdirectories too.

When the check fails, the script runs the same command again to capture the list of unformatted files using command substitution ($(...)). It then loops through each filename and prints it as a bullet point.

The final line check_formatting calls the function so the script actually does something when you run it.

  • Save validate.sh.
  • Run the script to confirm it works:
./validate.sh

You should see colored output reading PASS: Formatting check passed because your Terraform files are already properly formatted from Step 1.

Seeing "Permission denied"?

Run chmod +x validate.sh again. If you still see the error, make sure you are in the terraform-validator directory by checking with pwd.

Help me fix this

Test the script with a formatting error

The script reports PASS when everything is clean. But you need to know it actually catches problems. Time to deliberately break something and see if the script notices.

  • Open main.tf and find this line inside the azurerm_resource_group block:
  name     = var.resource_group_name
  • Add several extra spaces before var.resource_group_name so it looks like this:
  name     =     var.resource_group_name
  • Save main.tf.

Before you run the script, predict: will it pass or fail now that you have added extra whitespace?

  • Run the validation script:
./validate.sh

You should see FAIL: Formatting check failed in red, followed by main.tf listed as a file needing formatting. The script caught your intentional mistake.

  • Confirm the script returned a non-zero exit code by running:
echo $?

You should see 1. A non-zero exit code signals failure, which is how CI/CD systems know a check did not pass.

What are exit codes?

Every command in Bash produces an exit code when it finishes. Code 0 means success. Any other number (1, 2, 3, etc.) means something went wrong.

Automation tools check these codes to decide whether to continue or stop a pipeline. Your script uses return 0 for pass and return 1 for fail so it plays nicely with any automation that calls it.

  • Fix the formatting by running:
terraform fmt
  • Run the validation script one more time:
./validate.sh

You should see PASS: Formatting check passed in green. The script correctly detects both broken and clean formatting.

Still seeing FAIL after running terraform fmt?

Make sure you saved main.tf after the terraform fmt command ran. The formatter rewrites the file in place, but some editors hold a stale copy in memory. Close and reopen main.tf to confirm the extra spaces are gone.

Help me figure out why

✔️ Awesome, I've got everything!

Great. Double check you have saved validate.sh and that main.tf is back to its properly formatted state.

ⓧ I'd like to double check the full code

Your validate.sh should contain these sections in order:

  • The shebang line (#!/bin/bash) and two comment lines describing the script.
  • Five color variable assignments (RED, GREEN, YELLOW, BLUE, RESET).
  • The print_pass and print_fail helper functions.
  • The check_formatting function with the if/else block.
  • A call to check_formatting at the very bottom.

Scroll up and confirm each section is present in your file. Your main.tf, variables.tf, and outputs.tf should be unchanged from Step 1.

Your script catches formatting problems with a single command. Next up, you will add a second check that catches real syntax errors like typos in resource arguments.

Add Syntax Validation with JSON Parsing

Your formatting check catches cosmetic issues, but cosmetic issues don't break deployments. Real danger comes from typos in resource arguments or references to undeclared variables.

In this step, you'll add a syntax validation function that runs terraform validate -json and parses its structured output using jq. This teaches two core Bash patterns: command substitution (capturing command output into a variable) and piping JSON through jq to extract specific fields.

In this step, get ready to:
  • Add a check_syntax function that captures JSON output from Terraform.
  • Parse error counts and diagnostic messages using jq piping.
  • Test the script by introducing and fixing a deliberate syntax error.
Add the check_syntax function

The terraform validate -json command returns structured JSON with fields like .valid, .error_count, and .diagnostics[]. Your function will capture that entire JSON blob into a variable, then pipe it through jq to extract the values you need.

  • In validate.sh, add this check_syntax function below the closing } of your check_formatting function and above the check_formatting call at the bottom:
check_syntax() {
  echo ""
  echo -e "${BLUE}[2/3] Validating syntax...${RESET}"

  local validate_output
  validate_output=$(terraform validate -json 2>&1)

  local is_valid
  is_valid=$(echo "$validate_output" | jq -r '.valid')

  local error_count
  error_count=$(echo "$validate_output" | jq -r '.error_count')

  local warning_count
  warning_count=$(echo "$validate_output" | jq -r '.warning_count')

  if [ "$is_valid" = "true" ]; then
    print_pass "Validation passed (0 errors, $warning_count warnings)"
    return 0
  else
    print_fail "Validation found $error_count error(s)"
    echo "$validate_output" | jq -r '.diagnostics[] | select(.severity == "error") | "       - \(.summary): \(.detail // "no details")"'
    return 1
  fi
}

What does this code do?

This function introduces two key Bash patterns you'll use throughout the script.

  • Command substitution: $(terraform validate -json 2>&1) runs the command and stores its entire output in the validate_output variable.
  • jq piping: echo "$validate_output" | jq -r '.valid' sends the JSON to jq which extracts a single field. The -r flag outputs raw strings without quotes.
  • Error diagnostics: When validation fails, the jq filter iterates through .diagnostics[], selects only errors, and prints each summary with its detail.
Call the new function at the bottom of the script

The function exists but won't run until you call it. You need to add check_syntax after the existing check_formatting call at the bottom of the file.

  • In validate.sh, scroll to the very bottom and find this line:
check_formatting
  • Add check_syntax on a new line below it, so the bottom of your file looks like this:
check_formatting
check_syntax
  • Save validate.sh.
  • Run the script to confirm both checks pass on your current (valid) configuration:
./validate.sh

You should see the formatting check pass, followed by the syntax check reporting 0 errors.

Seeing a jq error?

If you see a message about jq not being found, confirm it is installed by running jq --version. If it is missing, install it with brew install jq.

If you see a JSON parsing error, make sure the quotes inside the jq filter on the diagnostics line are escaped correctly. help me debug my jq filter in validate.sh

Test the syntax check with a deliberate error

A passing test only proves the happy path works. To trust your script, you need to see it catch a real problem.

  • Open main.tf and find this line inside the azurerm_resource_group resource:
  location = var.location
  • Change location to locaton (remove the second i), so the line reads:
  locaton = var.location
  • Save main.tf.

Before you run the script, what do you think will happen? Will the formatting check catch this, or only the syntax check?

  • Run the validation script:
./validate.sh

The formatting check passes (a typo in an argument name is not a formatting issue). The syntax check fails and reports the error count along with the specific diagnostic message extracted from the JSON output.

Why did formatting pass?

terraform fmt only cares about whitespace and indentation. It has no idea whether locaton is a real argument. That is exactly why you need the syntax validation layer.

  • Fix the typo by changing locaton back to location in main.tf.
  • Save main.tf and re-run the script:
./validate.sh

Both checks now report PASS. Your script catches real configuration errors that formatting alone would miss.

Still seeing a syntax failure after fixing the typo?

Double-check that location is spelled correctly and sits inside the azurerm_resource_group block (not accidentally moved outside the braces). Also confirm you saved main.tf before re-running.

help me figure out why terraform validate is still failing after I fixed my typo

✔️ Awesome, I've got everything!

Great. Double check you have saved validate.sh and that main.tf is back to its original (valid) state.

ⓧ I'd like to double check the full code

Your validate.sh should have these sections in order. Scroll through and confirm each is present:

  • Shebang line and header comments.
  • Color variable declarations (RED, GREEN, YELLOW, BLUE, RESET).
  • print_pass and print_fail helper functions.
  • check_formatting function (runs terraform fmt -check -recursive).
  • check_syntax function (runs terraform validate -json, parses with jq).
  • Two function calls at the bottom: check_formatting then check_syntax.

Your script now catches both cosmetic and structural problems. Next up, you'll add the most powerful check: scanning the Terraform plan for dangerous changes like resource deletions.

Add Plan-Based Policy Enforcement

Your script catches formatting issues and syntax errors. Those are useful, but they are cosmetic and structural checks.

The real danger in infrastructure work is unintended changes. Someone removes a resource block, runs terraform apply, and a production resource group vanishes. In this step, you will add a plan-based policy check that catches destructive changes before they happen.

In this step, get ready to:
  • Add a check_plan function that generates a machine-readable Terraform plan.
  • Use jq to parse the plan JSON and detect resource deletions.
  • Test the policy by triggering both a PASS and a FAIL result.
Add the check_plan function

The terraform plan command compares your configuration against the current state and produces a list of changes it would make. By outputting the plan as JSON, you can parse it programmatically and enforce policies on what kinds of changes are allowed.

  • In validate.sh, find the two lines at the bottom of the file that call your existing functions:
check_formatting
check_syntax
  • Add the following check_plan function ABOVE those two lines, with a blank line separating the function from the calls:
check_plan() {
  echo ""
  echo -e "${BLUE}[3/3] Running plan policy checks...${RESET}"

  # Generate a plan file, suppress output
  if ! terraform plan -out=tfplan -input=false > /dev/null 2>&1; then
    print_fail "Terraform plan failed to generate"
    return 1
  fi

  # Convert the binary plan to JSON for parsing
  terraform show -json tfplan > tfplan.json 2>/dev/null

  # Count resources marked for deletion
  local delete_count
  delete_count=$(jq '[.resource_changes[] | select(.change.actions[] == "delete")] | length' tfplan.json)

  if [ "$delete_count" -gt 0 ]; then
    print_fail "Plan includes $delete_count resource deletion(s)"
    echo "       Resources to be deleted:"
    jq -r '.resource_changes[] | select(.change.actions[] == "delete") | "         - \(.address)"' tfplan.json
    return 1
  else
    local create_count
    create_count=$(jq '[.resource_changes[] | select(.change.actions[] == "create")] | length' tfplan.json)
    local update_count
    update_count=$(jq '[.resource_changes[] | select(.change.actions[] == "update")] | length' tfplan.json)
    print_pass "No destructive changes detected (creates: $create_count, updates: $update_count)"
    return 0
  fi
}

What does this function do?

This function works in three stages:

  • terraform plan -out=tfplan -input=false generates a saved plan file. The -input=false flag prevents interactive prompts, which is essential for automation.
  • terraform show -json tfplan > tfplan.json converts the binary plan file into JSON that jq can parse.
  • The jq filter walks .resource_changes[] and selects any entry where .change.actions[] contains "delete". If the count is greater than zero, the script fails and prints the affected resource addresses.
  • Now add check_plan as the third call at the bottom of the file, so the final three lines look like this:
check_formatting
check_syntax
check_plan
  • Save validate.sh.
  • Run the script to confirm it executes all three checks by running:
./validate.sh

You should see output from all three stages. The formatting and syntax checks pass as before, and the plan check prints PASS: No destructive changes detected because your current configuration only creates resources (there is no existing state to delete from).

Seeing 'Terraform plan failed to generate'?

  • Confirm your Azure CLI session is active by running az account show. If it returns an error, run az login to re-authenticate.
  • Make sure you are running the script from inside your terraform-validator project directory where .terraform exists.
  • help me debug why my Terraform plan is failing to generate
Test the policy with a resource change

The plan check just passed because your config only creates resources. To prove the deletion detection works, you need Terraform state that knows about a resource, then remove that resource from config so the plan marks it for deletion.

  • Add a second resource group to main.tf by pasting this block at the end of the file:
resource "azurerm_resource_group" "secondary" {
  name     = "rg-terraform-validator-secondary"
  location = var.location

  tags = {
    environment = var.environment
    managed_by  = "terraform"
  }
}
  • Save main.tf.
  • Apply this configuration to establish Terraform state by running:
terraform apply -auto-approve

Why do we need to apply here?

Terraform detects deletions by comparing your configuration against its state file. Without state, every resource is just a "create" and nothing can be a "delete". By applying once, Terraform records the resources in state so removing one from config triggers a deletion in the next plan.

Azure resource groups are free to create. This apply costs nothing.

Wait for the apply to complete. You should see output ending with Apply complete! Resources: 3 added, 0 changed, 0 destroyed. (or similar).

  • Run the validation script to confirm everything passes with the current state:
./validate.sh

All three checks should pass. The plan check reports PASS: No destructive changes detected because config and state are in sync.

Now comes the moment the policy check earns its keep. Remove the secondary resource group from config to simulate someone accidentally deleting infrastructure.

  • In main.tf, delete the entire azurerm_resource_group "secondary" block you just added (the last 8 lines of the file).
  • Save main.tf.

Before you run the script, what do you think will happen? The secondary resource group still exists in Azure and in Terraform state, but it is no longer in your config.

  • Run the validation script:
./validate.sh

The plan check catches the deletion and reports:

  FAIL: Plan includes 1 resource deletion(s)
       Resources to be deleted:
         - azurerm_resource_group.secondary

That is exactly the kind of dangerous change this script was built to catch. Without the policy check, someone could run terraform apply and destroy a resource group without realizing it.

Why this matters in production

In a real team, this script would run in a CI/CD pipeline before any apply step. A non-zero exit code blocks the deployment, forcing the engineer to acknowledge and approve the deletion manually rather than letting it slip through.

  • Clean up the test resources by running:
terraform destroy -auto-approve

This removes the Azure resources and state so your project is back to a clean slate.

Script shows PASS instead of FAIL?

  • Make sure you ran terraform apply -auto-approve before removing the resource block. Without state, Terraform cannot detect a deletion.
  • Confirm you saved main.tf after removing the secondary resource group block.
  • help me figure out why my plan check isn't detecting the deletion

✔️ Awesome, I've got everything!

Great. Double check you have saved validate.sh with the check_plan function and the three function calls at the bottom.

ⓧ I'd like to double check the full code

Here is the complete validate.sh at this point. Scroll through and confirm each section is present in order.

Your file has these sections:

  • Shebang and header comments.
  • Color code variables (RED, GREEN, YELLOW, BLUE, RESET).
  • print_pass and print_fail helper functions.
  • check_formatting function (runs terraform fmt -check -recursive).
  • check_syntax function (runs terraform validate -json and parses with jq).
  • check_plan function (runs terraform plan, converts to JSON, checks for deletions with jq).
  • Three function calls at the bottom: check_formatting, check_syntax, check_plan.

Your validation script now catches the most dangerous class of infrastructure change: unintended deletions. Next up, you will combine all three checks into a polished pipeline with a summary report and proper exit codes for automation.

Combine Everything into a Pipeline with Summary Report

Your script now catches formatting issues, syntax errors, and dangerous planned deletions. Each check works, but they run independently with no overall result.

A real validation pipeline reports all results together and exits with a single code that automation tools can check. CI/CD scripts rely on exit codes to decide pass or fail. In this step, you will combine the checks into a unified pipeline with a colored summary report.

In this step, get ready to:
  • Add result-tracking infrastructure using Bash arrays and counters.
  • Build a print_summary function that displays a colored pass/fail report.
  • Add automatic cleanup of temporary files using a trap command.
Add result-tracking infrastructure

Right now, each check function prints its own result and returns. There is no central place where results accumulate. You need a way to record every check's outcome so a final summary can report them all at once.

  • In validate.sh, find the RESET color variable line. Add the following code below it:
# Track results
declare -a CHECK_NAMES
declare -a CHECK_RESULTS
TOTAL_PASS=0
TOTAL_FAIL=0

# Cleanup temporary files on exit
trap 'rm -f tfplan tfplan.json' EXIT

print_header() {
  echo ""
  echo -e "${BLUE}========================================${RESET}"
  echo -e "${BLUE}  Terraform Validation Pipeline${RESET}"
  echo -e "${BLUE}========================================${RESET}"
  echo ""
}

print_warn() {
  echo -e "  ${YELLOW}WARN${RESET}: $1"
}

print_skip() {
  echo -e "  ${YELLOW}SKIP${RESET}: $1"
}

What does this code do?

declare -a creates indexed arrays in Bash. CHECK_NAMES stores the name of each check and CHECK_RESULTS stores whether it passed or failed.

The trap command registers a cleanup action that fires when the script exits, even if it fails midway. This ensures the tfplan and tfplan.json temp files never litter your project directory.

print_header outputs a blue banner so you immediately see where the pipeline output starts. print_warn and print_skip round out the helper set for future use.

Now you need a function that records each check's outcome into those arrays.

  • Below the print_skip function you just added (and above the existing print_pass function), add the record_result function:
record_result() {
  local name="$1"
  local result="$2"
  CHECK_NAMES+=("$name")
  CHECK_RESULTS+=("$result")
  if [ "$result" = "PASS" ]; then
    TOTAL_PASS=$((TOTAL_PASS + 1))
  else
    TOTAL_FAIL=$((TOTAL_FAIL + 1))
  fi
}

What does this code do?

record_result takes a check name and its result (PASS or FAIL), appends them to the arrays with +=, and increments the appropriate counter using arithmetic expansion $(( )).

Next, update the main execution block at the bottom of the file so it calls print_header before the checks.

  • Scroll to the bottom of validate.sh. Find these three lines:
check_formatting
check_syntax
check_plan
  • Replace them with:
print_header
check_formatting
check_syntax
check_plan
  • Save validate.sh and run the script to confirm the new banner appears:
./validate.sh

You should see a blue bordered banner reading "Terraform Validation Pipeline" at the top, followed by the familiar check output.

Not seeing the banner?

  • Make sure the print_header call is above check_formatting at the very bottom of the file.
  • Check that the print_header function definition uses matching curly braces and is placed between trap and print_warn.
  • If you see a syntax error mentioning declare, confirm your shebang is #!/bin/bash (not #!/bin/sh).

Why isn't my validate.sh showing the pipeline banner?

Wire record_result into each check function

Each check function currently prints its result and returns. You need to also call record_result so the summary knows what happened.

  • In the check_formatting function, find the pass branch. It currently looks like this:
    print_pass "Formatting check passed"
    return 0
  • Add a record_result call between them, so it looks like this:
    print_pass "Formatting check passed"
    record_result "Formatting" "PASS"
    return 0
  • In the same function's fail branch, find the done line (end of the while loop) followed by return 1. Add the recording call between them:
    done
    record_result "Formatting" "FAIL"
    return 1
  • Apply the same pattern to check_syntax. Add record_result "Syntax" "PASS" before return 0 in the pass branch.
  • Add record_result "Syntax" "FAIL" before return 1 in the fail branch.
  • Apply the same pattern to check_plan. This function has three exit points:
  • After print_fail "Terraform plan failed to generate", add record_result "Plan Policy" "FAIL".
  • In the deletions-detected branch, after the jq line that prints resource addresses, add record_result "Plan Policy" "FAIL".
  • In the success branch, after print_pass "No destructive changes detected...", add record_result "Plan Policy" "PASS".
  • Save validate.sh and run the script to confirm it still executes without errors:
./validate.sh

You should see the same banner and check output as before. The results are being recorded silently in the background. The next step makes them visible.

Add the summary report

The tracking arrays now hold the outcome of every check. You need a function that reads those arrays and prints a formatted summary with totals and an overall pass/fail verdict.

  • In validate.sh, add the print_summary function below the check_plan function (and above the main execution block at the bottom):
print_summary() {
  echo ""
  echo -e "${BLUE}========================================${RESET}"
  echo -e "${BLUE}  Summary${RESET}"
  echo -e "${BLUE}========================================${RESET}"
  echo ""

  for i in "${!CHECK_NAMES[@]}"; do
    local status="${CHECK_RESULTS[$i]}"
    if [ "$status" = "PASS" ]; then
      echo -e "  ${GREEN}PASS${RESET}  ${CHECK_NAMES[$i]}"
    elif [ "$status" = "SKIP" ]; then
      echo -e "  ${YELLOW}SKIP${RESET}  ${CHECK_NAMES[$i]}"
    else
      echo -e "  ${RED}FAIL${RESET}  ${CHECK_NAMES[$i]}"
    fi
  done

  echo ""
  echo -e "  Results: ${GREEN}$TOTAL_PASS passed${RESET}, ${RED}$TOTAL_FAIL failed${RESET}"
  echo ""

  if [ "$TOTAL_FAIL" -gt 0 ]; then
    echo -e "  ${RED}Validation failed!${RESET}"
    return 1
  else
    echo -e "  ${GREEN}All checks passed!${RESET}"
    return 0
  fi
}

What does this code do?

${!CHECK_NAMES[@]} iterates over the array indices, letting you access both CHECK_NAMES[$i] and CHECK_RESULTS[$i] in parallel.

The function prints each check with its colored status, then totals, then a final verdict. It returns 0 (success) only when every check passed. This return value becomes the script's exit code.

Now update the main execution block to call print_summary and exit with the right code.

  • Find the main execution block at the bottom of the file. It currently looks like this:
print_header
check_formatting
check_syntax
check_plan
  • Replace it with:
# --- Main ---

print_header
check_formatting
check_syntax
check_plan
print_summary
exit $?

What does exit $? do?

$? holds the exit status of the last command. Since print_summary returns 1 when any check failed and 0 when all passed, exit $? passes that directly to the calling process. CI/CD tools use this to decide if the pipeline should continue or halt.

Before you run this, what do you think the summary will show? All three checks should have data to report.

  • Save validate.sh and run the full pipeline:
./validate.sh

You should see the blue banner, all three checks running with PASS results, then a summary table listing each check name with a green PASS status. At the bottom: "Results: 3 passed, 0 failed" and "All checks passed!"

Seeing errors about CHECK_NAMES or CHECK_RESULTS?

  • Confirm the declare -a lines are above all function definitions. Bash arrays must be declared before any function tries to append to them.
  • Check that your record_result calls use double quotes around both arguments: record_result "Formatting" "PASS".
  • Make sure the for i in "${!CHECK_NAMES[@]}" line in print_summary uses the ! prefix to iterate indices, not values.

Why is my validate.sh summary not printing results correctly?

Test the failure path

A pipeline that only shows success is only half-tested. You need to confirm that a failure shows up clearly in the summary and that the script exits with a non-zero code.

  • Open main.tf and add a few extra spaces before name in the resource group block to break formatting:
resource "azurerm_resource_group" "example" {
      name     = var.resource_group_name
  location = var.location
  • Save main.tf.

What do you think the summary will look like now? One check should fail while the others still pass.

  • Run the pipeline:
./validate.sh

The summary now shows Formatting as FAIL (in red) while Syntax and Plan Policy remain PASS. The final line reads "Validation failed!" and the results show "2 passed, 1 failed".

  • Confirm the exit code is non-zero by running:
echo $?

You should see 1. Any CI/CD system running this script would detect the non-zero exit and halt the deployment.

  • Fix the formatting by running:
terraform fmt
  • Run ./validate.sh one more time to confirm all checks pass again.
  • Verify the temp files are cleaned up by running:
ls tfplan*

You should see an error like "No such file or directory". The trap command automatically removed the temp files when the script exited.

Still seeing tfplan files after the script runs?

  • Make sure the trap line uses single quotes around the command: trap 'rm -f tfplan tfplan.json' EXIT. Double quotes would cause the variables to expand at definition time.
  • Confirm the trap line appears near the top of the file (after variable declarations, before function definitions). It must be registered before the functions run.

Why isn't my trap command cleaning up temp files?

Why use trap instead of just deleting at the end?

If the script fails partway through (say, during the plan step), a plain rm at the bottom would never execute. The trap command fires on EXIT regardless of how the script ends. This guarantees cleanup even on unexpected failures.

✔️ Awesome, I've got everything!

Great. Double check you have saved validate.sh and that running ./validate.sh shows the banner, all three checks, and the summary report.

ⓧ I'd like to double check the full code

Scroll up and confirm your validate.sh contains each of these sections in order:

  • Shebang (#!/bin/bash) and header comments.
  • Color code variables (RED, GREEN, YELLOW, BLUE, RESET).
  • Tracking variables (declare -a CHECK_NAMES, declare -a CHECK_RESULTS, TOTAL_PASS, TOTAL_FAIL).
  • Trap command: trap 'rm -f tfplan tfplan.json' EXIT.
  • Helper functions: print_header, print_pass, print_fail, print_warn, print_skip, record_result.
  • Check functions (check_formatting, check_syntax, check_plan) each calling record_result in their pass and fail branches.
  • print_summary function that iterates the arrays and prints the verdict.
  • Main execution: print_header, three check calls, print_summary, exit $?.

Your validation script is now a complete pipeline. It runs all three checks, tracks every result, cleans up after itself, and exits with a code that automation tools can act on. This is a real DevOps tool you can drop into any Terraform project.

Secret mission

Add Command-Line Argument Parsing

Your validation script works, but it always runs every check. What if you just need a quick formatting and syntax check without waiting for the plan? Or what if you want to be extra strict about warnings? Add command-line flags to make the script flexible enough for real-world use.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Project directory (terraform-validator) containing your Terraform configuration files and validate.sh script.
  • The .terraform directory with downloaded provider plugins (inside your project folder).
  • Local Terraform state file (terraform.tfstate) created during plan operations.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep the validation script handy for other Terraform projects.

  • The project sits on your local machine with no running services or costs.
  • You can copy validate.sh into any other Terraform project directory and run it there.
  • The .terraform directory takes up disk space (provider plugins) but causes no other impact.

Pause - I'll come back to this later

Since nothing is running, there is nothing to pause. Your project is already in a dormant state with no active processes or costs.

  • If you want to free up disk space from provider plugins, delete the .terraform directory. You can re-download plugins later by running terraform init again.
  • Remove the .terraform directory by running:
cd ~/Desktop/terraform-validator
rm -rf .terraform
  • Your configuration files and script remain intact for when you return.

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes everything you built in this project.

  • Delete the entire project directory (which includes .terraform, terraform.tfstate, all .tf files, and validate.sh) by running:
rm -rf ~/Desktop/terraform-validator
  • Confirm the directory is gone by running:
ls ~/Desktop | grep terraform-validator
  • You should see no output, confirming the folder has been removed.
  • No cloud resources were provisioned during this project, so there is nothing to delete in Azure.

Nice Work!

Nice Work!

You just built a real DevOps tool from scratch. Your validate.sh script catches formatting issues, syntax errors, and dangerous resource deletions before they ever reach production.

You've learned how to:

  • Build a Bash script from scratch using shebang lines, chmod, variables, ANSI color codes, functions, conditionals, exit codes, arrays, and trap for cleanup.
  • Parse structured JSON output from Terraform commands using jq and command substitution to extract validation errors, warning counts, and planned resource changes.
  • Create a reusable validation pipeline that enforces formatting, syntax, and plan-based policy checks with a color-coded summary report and proper exit codes for automation.
  • Secret Mission: Added command-line argument parsing with --skip-plan, --strict, and --help flags using a while/case loop.

Ready to quiz yourself?