Automation23 min read

Bash for Azure and cloud operations

Write dependable Bash automation with strict error handling, Azure CLI, JSON queries, retries, safe CI/CD patterns, and troubleshooting.

Production-aware guide

Review scope, permissions, impact, and rollback before applying changes.

Bash for Azure and cloud operations

Bash is a command language and shell widely used on Linux, in containers, Azure Cloud Shell, and CI/CD agents. A reliable cloud-automation script should validate input, quote expansions, handle failures explicitly, avoid leaking secrets, produce useful logs, and verify every important change.

Who this guide is for

This guide covers:

  • Bash variables, quoting, arrays, conditions, loops, and functions.
  • Safe script structure and error handling.
  • Azure CLI authentication and JSON queries.
  • Idempotent Azure automation.
  • Retries, temporary files, cleanup, and logging.
  • CI/CD and security practices.
  • Common Bash and Azure CLI failures.

Start with a predictable script

#!/usr/bin/env bash

set -Eeuo pipefail

main() {
  printf '%s\n' 'Starting CloudForge operation'
}

main "$@"

The options mean:

  • -E: inherit error traps in functions and subshell contexts where supported.
  • -e: exit when an unhandled command fails.
  • -u: treat an unset variable as an error.
  • -o pipefail: fail a pipeline if any command in it fails.

Strict mode is useful but not magic. Some shell contexts modify errexit behavior. Handle expected failures explicitly and test every execution path.

Variables and quoting

Assign without spaces around =:

resource_group='rg-cloudforge-dev'
location='westeurope'

Quote expansions:

printf 'Resource group: %s\n' "$resource_group"

Unquoted expansions can be split into words and expanded as filename patterns. Use arrays when passing a dynamic list of arguments.

az_args=(
  group create
  --name "$resource_group"
  --location "$location"
  --tags managedBy=bash workload=cloudforge-example
  --output json
)

az "${az_args[@]}"

Positional arguments

Validate required input:

usage() {
  printf 'Usage: %s <subscription-id> <resource-group> <location>\n' "$0" >&2
}

if (( $# != 3 )); then
  usage
  exit 64
fi

subscription_id=$1
resource_group=$2
location=$3

Use ${variable:-default} for an optional default and ${variable:?message} for required environment variables:

environment=${ENVIRONMENT:-dev}
: "${AZURE_SUBSCRIPTION_ID:?AZURE_SUBSCRIPTION_ID must be set}"

Do not put secrets in error messages.

Conditions

Use [[ ... ]] for Bash conditionals:

if [[ $environment == 'prod' ]]; then
  printf '%s\n' 'Production protections enabled.'
fi

Validate a value:

case "$environment" in
  dev|test|prod) ;;
  *)
    printf 'Unsupported environment: %s\n' "$environment" >&2
    exit 64
    ;;
esac

Numeric comparison:

if (( replica_count < 2 )); then
  printf '%s\n' 'At least two replicas are required.' >&2
  exit 1
fi

Loops

Use arrays to preserve item boundaries:

resource_groups=(
  'rg-cloudforge-dev'
  'rg-cloudforge-test'
)

for group in "${resource_groups[@]}"; do
  az group show --name "$group" --output none
done

Do not loop over the output of ls. Use globs, arrays, find with safe delimiters, or structured command output.

Functions

log() {
  local level=$1
  shift
  printf '%s [%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$level" "$*" >&2
}

require_command() {
  local command_name=$1

  if ! command -v "$command_name" >/dev/null 2>&1; then
    log ERROR "Required command not found: $command_name"
    return 127
  fi
}

Keep functions focused. Use local variables, return status codes for success or failure, and write data output to standard output while sending logs to standard error.

Error handling and cleanup

temporary_directory=''

cleanup() {
  local exit_code=$?

  if [[ -n $temporary_directory && -d $temporary_directory ]]; then
    rm -rf -- "$temporary_directory"
  fi

  exit "$exit_code"
}

on_error() {
  local exit_code=$1
  local line_number=$2
  log ERROR "Command failed with exit code $exit_code at line $line_number"
}

trap 'on_error "$?" "$LINENO"' ERR
trap cleanup EXIT

temporary_directory=$(mktemp -d)

Only remove a temporary directory created and stored by the script. Never run a recursive delete against an empty, unresolved, or broadly scoped path.

Azure CLI login and context

Interactive development:

az login
az account set --subscription '<subscription-id>'
az account show --query '{name:name,id:id,tenantId:tenantId}' --output table

Production automation should prefer managed identity or workload identity federation. Managed identity example on a compatible Azure host:

az login --identity --output none
az account set --subscription "$AZURE_SUBSCRIPTION_ID"

Validate the target before changes:

actual_subscription_id=$(az account show --query id --output tsv)

if [[ $actual_subscription_id != "$AZURE_SUBSCRIPTION_ID" ]]; then
  log ERROR "Azure subscription validation failed."
  exit 1
fi

Use identifiers, not display names, for critical target validation.

Query Azure CLI output

Prefer JMESPath through --query so Azure CLI returns only the required fields:

az group list \
  --query "[].{name:name,location:location}" \
  --output table

Capture a single value with TSV:

webapp_hostname=$(az webapp show \
  --resource-group "$resource_group" \
  --name "$webapp_name" \
  --query defaultHostName \
  --output tsv)

For complex local JSON transformation, use jq:

az resource list --resource-group "$resource_group" --output json |
  jq -r '.[] | [.name, .type, .location] | @tsv'

Do not parse colorized or table-formatted output with grep and fixed columns.

Create a resource group idempotently

ensure_resource_group() {
  local name=$1
  local location=$2

  if az group exists --name "$name" --output tsv | grep -qx 'true'; then
    local existing_location
    existing_location=$(az group show --name "$name" --query location --output tsv)

    if [[ $existing_location != "$location" ]]; then
      log ERROR "Resource group exists in $existing_location, not $location."
      return 1
    fi

    log INFO "Resource group already exists: $name"
    return 0
  fi

  log INFO "Creating resource group: $name"
  az group create \
    --name "$name" \
    --location "$location" \
    --tags managedBy=bash workload=cloudforge-example \
    --output none
}

Idempotency means repeat execution converges on the intended state instead of creating duplicates or failing unnecessarily.

Handle expected failures explicitly

With set -e, expected negative tests should be part of a conditional:

if az resource show --ids "$resource_id" --output none 2>/dev/null; then
  log INFO 'Resource exists.'
else
  log INFO 'Resource does not exist or is not accessible.'
fi

Be careful: “not found” and “not authorized” are different. Capture and classify error output where that distinction matters.

Retry transient Azure operations

retry() {
  local maximum_attempts=$1
  shift

  local attempt=1
  local delay_seconds=2

  until "$@"; do
    if (( attempt >= maximum_attempts )); then
      log ERROR "Command failed after $attempt attempts."
      return 1
    fi

    log WARN "Attempt $attempt failed; retrying in $delay_seconds seconds."
    sleep "$delay_seconds"
    ((attempt++))
    delay_seconds=$((delay_seconds * 2))
  done
}

retry 4 az webapp show \
  --resource-group "$resource_group" \
  --name "$webapp_name" \
  --output none

Production retry logic should add jitter, cap maximum delay, honor Retry-After when available, and retry only transient errors. Never retry an unsafe non-idempotent action without confirming its semantics.

HTTP health verification

verify_health() {
  local health_url=$1

  curl \
    --fail \
    --silent \
    --show-error \
    --location \
    --connect-timeout 5 \
    --max-time 20 \
    "$health_url" >/dev/null
}

For a deployment, check the response status, content or version, TLS behavior, and critical dependency path. A single HTTP 200 may be insufficient evidence of complete recovery.

Safe file handling

Quote file paths and use -- before path arguments where supported:

cp -- "$source_file" "$destination_file"

Read lines without losing backslashes or trimming whitespace:

while IFS= read -r line; do
  printf '%s\n' "$line"
done < "$input_file"

Create temporary files and directories with mktemp. Apply restrictive permissions to files that may contain sensitive information.

Secrets

  • Prefer managed identity or federation over reusable secrets.
  • Disable command tracing around secret operations.
  • Do not pass secrets as command-line arguments when a safer mechanism exists.
  • Do not include secrets in environment dumps, logs, artifacts, URLs, or process titles.
  • Quote variables that may contain special characters.
  • Rotate any value exposed in logs.
  • Use an approved secret manager and short-lived access.

Avoid globally enabling set -x in CI/CD because it prints expanded commands and can reveal data.

CI/CD script pattern

#!/usr/bin/env bash
set -Eeuo pipefail

readonly script_name=${0##*/}

log() {
  local level=$1
  shift
  printf '%s [%s] [%s] %s\n' \
    "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
    "$level" \
    "$script_name" \
    "$*" >&2
}

main() {
  : "${AZURE_SUBSCRIPTION_ID:?Required variable is missing}"
  : "${RESOURCE_GROUP:?Required variable is missing}"

  command -v az >/dev/null 2>&1 || {
    log ERROR 'Azure CLI is not installed.'
    return 127
  }

  az account set --subscription "$AZURE_SUBSCRIPTION_ID"

  local actual_subscription_id
  actual_subscription_id=$(az account show --query id --output tsv)

  [[ $actual_subscription_id == "$AZURE_SUBSCRIPTION_ID" ]] || {
    log ERROR 'Unexpected Azure subscription context.'
    return 1
  }

  log INFO "Validated Azure context for resource group $RESOURCE_GROUP."
}

main "$@"

Pin or record the Bash and Azure CLI versions used by the runner. Run ShellCheck during pull-request validation.

Exit status and control flow

Every command returns an integer status; zero normally means success. Check expected outcomes directly:

if az group show --name "$resource_group" --output none 2>/dev/null; then
  log INFO 'Resource group is accessible.'
else
  status=$?
  log WARN "Resource-group lookup failed with status $status."
fi

Do not write command || true merely to silence a failure. If failure is acceptable, explain why and handle the status or output explicitly.

Commands joined with && run the second command only when the first succeeds. Commands joined with || run the second only when the first fails. Long chains become difficult to diagnose; use clear if statements for operational logic.

Parameter expansion

Bash parameter expansion can validate and transform values without spawning another process:

environment=${ENVIRONMENT:-dev}
readonly resource_group=${RESOURCE_GROUP:?RESOURCE_GROUP is required}
filename=${path##*/}
directory=${path%/*}

Distinguish unset from empty values when that difference matters. With set -u, reference optional variables through a deliberate default.

Associative arrays

Associative arrays can represent small keyed configurations:

declare -A locations=(
  [dev]='westeurope'
  [test]='northeurope'
  [prod]='westeurope'
)

location=${locations[$environment]:?No location configured for environment}

For large or shared configuration, prefer a validated JSON, YAML, or platform-native configuration file rather than embedding an unstructured collection in the script.

Command substitution and subshells

Command substitution captures standard output:

resource_id=$(az group show \
  --name "$resource_group" \
  --query id \
  --output tsv)

Trailing newlines are removed. Logs written to standard output can corrupt the captured value, which is why reusable functions should write diagnostic messages to standard error.

A pipeline loop may run in a subshell depending on shell settings, so variable updates can disappear:

count=0
while IFS= read -r item; do
  ((count += 1))
done < <(printf '%s\n' one two three)

printf 'Count: %d\n' "$count"

Process substitution above keeps the loop in the current Bash process.

Read JSON safely

Use jq -e when the filter must produce a successful truthy result:

if az webapp show \
  --resource-group "$resource_group" \
  --name "$webapp_name" \
  --output json |
  jq -e '.state == "Running"' >/dev/null; then
  log INFO 'Web app resource state is Running.'
else
  log ERROR 'Web app is not in the expected resource state.'
  exit 1
fi

Resource state alone does not prove application health. Follow with an HTTP or synthetic check.

When generating JSON, let jq escape values:

payload=$(jq -n \
  --arg name "$resource_group" \
  --arg location "$location" \
  '{name: $name, location: $location}')

Do not build JSON by concatenating unescaped strings.

Input files and delimiters

For null-delimited filenames:

while IFS= read -r -d '' file; do
  printf 'Checking %q\n' "$file"
done < <(find ./scripts -type f -name '*.sh' -print0)

This handles spaces and newlines in filenames. For cloud resource lists, prefer structured JSON queries instead of parsing human-formatted lines.

Concurrency

Background tasks can speed independent reads:

pids=()

for group in "${resource_groups[@]}"; do
  az group show --name "$group" --output json >"$temporary_directory/$group.json" &
  pids+=("$!")
done

failure=0
for pid in "${pids[@]}"; do
  if ! wait "$pid"; then
    failure=1
  fi
done

(( failure == 0 )) || exit 1

Set a concurrency limit for large collections. Do not parallelize dependent changes or operations targeting the same Terraform state, deployment, database, or mutable resource.

Locks for local coordination

On supported Linux systems, flock can prevent overlapping local executions:

exec 9>"${XDG_RUNTIME_DIR:-/tmp}/cloudforge-deploy.lock"

if ! flock -n 9; then
  log ERROR 'Another deployment process is already running.'
  exit 75
fi

A local lock does not coordinate separate agents or machines. Use the CI/CD platform or remote system's locking mechanism for distributed automation.

Azure REST calls

Prefer Azure CLI commands when they expose the required operation. For an ARM endpoint not yet covered, use az rest with an authenticated Azure CLI context:

az rest \
  --method get \
  --url 'https://management.azure.com/subscriptions/<subscription-id>/resourceGroups/<resource-group>?api-version=2024-03-01' \
  --query '{name:name,location:location,id:id}' \
  --output json

Use a documented API version and exact resource ID. Understand PUT versus PATCH behavior before sending data, and never insert an access token manually when az rest can handle authentication.

App Service deployment pattern

deploy_webapp_package() {
  local resource_group_name=$1
  local webapp_name=$2
  local package_path=$3

  [[ -f $package_path ]] || {
    log ERROR "Package not found: $package_path"
    return 66
  }

  az webapp deploy \
    --resource-group "$resource_group_name" \
    --name "$webapp_name" \
    --src-path "$package_path" \
    --type zip \
    --output none

  local hostname
  hostname=$(az webapp show \
    --resource-group "$resource_group_name" \
    --name "$webapp_name" \
    --query defaultHostName \
    --output tsv)

  retry 5 verify_health "https://$hostname/health"
}

For production, prefer a staging slot when supported, validate it, and promote the tested artifact. Record the artifact digest and deployment identifier.

Kubernetes diagnostics from Bash

collect_kubernetes_evidence() {
  local namespace=$1
  local output_directory=$2

  mkdir -p -- "$output_directory"

  kubectl get pods -n "$namespace" -o wide \
    >"$output_directory/pods.txt"
  kubectl get events -n "$namespace" --sort-by=.lastTimestamp \
    >"$output_directory/events.txt"
  kubectl get deployment,service,endpointslice -n "$namespace" -o yaml \
    >"$output_directory/workload.yaml"
}

Review files for secrets before storing them as build artifacts. Kubernetes object YAML can contain environment values and configuration not suitable for broad access.

Testing Bash scripts

Use several layers:

  1. bash -n for syntax.
  2. ShellCheck for common defects.
  3. Unit tests for functions and branching.
  4. Tests with filenames and values containing spaces and special characters.
  5. Failure-path tests for missing tools, denied access, timeouts, and partial output.
  6. Integration tests in an isolated Azure environment.
  7. Post-deployment verification.

Example validation job:

bash -n ./scripts/*.sh
shellcheck ./scripts/*.sh
./tests/run.sh

Pin or record ShellCheck and test-framework versions in CI.

Portability

A Bash script may rely on Bash arrays, [[ ]], process substitution, or other features absent from POSIX sh. Declare Bash in the shebang and execute the script directly or through bash.

If portability to /bin/sh is a requirement, write and test POSIX shell deliberately. Do not label a Bash-specific script as generic shell.

Differences also exist between GNU and BSD utilities. Container images and macOS runners may provide different options for date, sed, readlink, and other tools.

Complete production script checklist

  1. Declare the required shell.
  2. Enable deliberate error handling.
  3. Validate arguments, environment, and tools.
  4. Create private temporary storage.
  5. Register cleanup traps.
  6. Authenticate without exposing reusable secrets.
  7. Validate tenant, subscription, environment, and resource ID.
  8. Inspect current state.
  9. Make the smallest idempotent change.
  10. Retry only safe transient failures.
  11. Verify platform and application health.
  12. Emit structured, secret-free evidence.
  13. Return a meaningful status.

Troubleshooting decision table

SymptomFirst evidenceLikely causeSafe next step
command not foundcommand -v and PATHMissing tool or wrong runnerUse approved agent image
unbound variableFailing lineMissing required inputValidate with ${name:?message}
Unexpected word splittingPrinted argument countUnquoted expansionQuote values or use arrays
Pipeline hides failurePIPESTATUS or pipe behaviorMissing pipefailEnable and test pipefail
Works locally, fails in CIVersion and environment diffShell/tool/identity mismatchReproduce in the runner image
Azure 403Account and scopeRBAC or policyIdentify exact operation and principal
JSON parsing failsRaw non-secret responseWrong query or mixed log outputSeparate stdout data from stderr logs
Script runs twiceJob/build identifiersMissing distributed lockAdd platform-level concurrency control

Destructive operations

Require explicit confirmation or a protected pipeline environment before deletion:

delete_resource_group() {
  local resource_group_name=$1
  local environment=$2

  if [[ $environment == 'prod' ]]; then
    log ERROR 'This script does not permit production deletion.'
    return 1
  fi

  az group show --name "$resource_group_name" --output none

  az group delete \
    --name "$resource_group_name" \
    --yes \
    --no-wait
}

The sample is not a complete governance control. Also verify subscription, tenant, resource ID, locks, tags, backups, dependencies, and approvals.

Troubleshooting

command not found

Check whether the command is installed and in PATH:

command -v az
command -v jq
printf '%s\n' "$PATH"

CI agents may use a different image or non-interactive environment than a local terminal.

unbound variable

With set -u, the variable was not assigned. Validate required input with ${name:?message} or use a deliberate default such as ${name:-default}.

syntax error near unexpected token

Check quoting, missing then/fi, Windows CRLF line endings, and whether the script is being executed by sh instead of Bash.

bash -n script.sh
file script.sh

bad interpreter: No such file or directory

The shebang may reference an unavailable path or contain a carriage return. Use #!/usr/bin/env bash where appropriate and commit shell scripts with LF line endings.

Pipeline succeeds even though an earlier command failed

Enable set -o pipefail and inspect pipelines. Without it, a pipeline normally returns the status of its final command.

Azure CLI returns AuthorizationFailed

Confirm current identity, tenant, subscription, action, and RBAC scope:

az account show --output table
az account get-access-token --query expiresOn --output tsv

Do not print the access token itself.

Azure CLI JSON query returns null

Check the raw JSON structure for non-sensitive output, verify property capitalization and JMESPath expression, and confirm the resource exists in the selected subscription.

Script works locally but fails in CI

Compare shell version, working directory, environment variables, login mode, tool versions, file permissions, line endings, network/proxy configuration, and cloud identity.

Arguments split unexpectedly

Quote expansions and use arrays. Replace unsafe patterns such as $options with "${options[@]}".

Retry loop stops unexpectedly with strict mode

Arithmetic expressions can return a non-zero status. Use forms that do not accidentally trigger errexit, and test the loop under the exact Bash version used in CI.

Quality checklist

  • #!/usr/bin/env bash is used when Bash is required.
  • set -Eeuo pipefail behavior is understood and tested.
  • All expansions are quoted unless splitting is intentional.
  • Arrays carry argument lists.
  • Required commands and inputs are validated.
  • Azure subscription and tenant are confirmed.
  • Expected errors are handled explicitly.
  • Temporary resources are cleaned through traps.
  • Destructive operations have multiple safeguards.
  • Secrets are absent from code and logs.
  • Output is structured and useful.
  • ShellCheck runs in CI.
  • The script is tested with representative failures.

Command quick reference

bash --version
bash -n script.sh
shellcheck script.sh
az version
az login
az account show --output table
az account set --subscription '<subscription-id>'
az group list --output table
az resource list --resource-group '<resource-group>' --output table

Frequently asked questions

Why use Bash instead of putting all commands directly in a pipeline file?

A versioned, tested script can be run locally and across CI/CD platforms. It also keeps orchestration configuration focused.

Should every Bash script use strict mode?

Strict mode is a strong default for automation, but authors must understand exceptions and test expected failures. Add it deliberately rather than mechanically.

Should Azure CLI output be parsed with grep?

Prefer --query with JSON or TSV output, or use jq. Table formatting is intended for people and may change.

How should Bash authenticate to Azure in CI/CD?

Prefer workload identity federation or managed identity. Avoid long-lived service-principal secrets when a secretless option is available.

Official references