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:
bash -nfor syntax.- ShellCheck for common defects.
- Unit tests for functions and branching.
- Tests with filenames and values containing spaces and special characters.
- Failure-path tests for missing tools, denied access, timeouts, and partial output.
- Integration tests in an isolated Azure environment.
- 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
- Declare the required shell.
- Enable deliberate error handling.
- Validate arguments, environment, and tools.
- Create private temporary storage.
- Register cleanup traps.
- Authenticate without exposing reusable secrets.
- Validate tenant, subscription, environment, and resource ID.
- Inspect current state.
- Make the smallest idempotent change.
- Retry only safe transient failures.
- Verify platform and application health.
- Emit structured, secret-free evidence.
- Return a meaningful status.
Troubleshooting decision table
| Symptom | First evidence | Likely cause | Safe next step |
|---|---|---|---|
command not found | command -v and PATH | Missing tool or wrong runner | Use approved agent image |
unbound variable | Failing line | Missing required input | Validate with ${name:?message} |
| Unexpected word splitting | Printed argument count | Unquoted expansion | Quote values or use arrays |
| Pipeline hides failure | PIPESTATUS or pipe behavior | Missing pipefail | Enable and test pipefail |
| Works locally, fails in CI | Version and environment diff | Shell/tool/identity mismatch | Reproduce in the runner image |
| Azure 403 | Account and scope | RBAC or policy | Identify exact operation and principal |
| JSON parsing fails | Raw non-secret response | Wrong query or mixed log output | Separate stdout data from stderr logs |
| Script runs twice | Job/build identifiers | Missing distributed lock | Add 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 bashis used when Bash is required.set -Eeuo pipefailbehavior 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.