BLOG // [DEVOPS] Bash and Cloud CLI: Manage cloud efficiently with AWS, GCP, Azure
Bash and Cloud CLI: Manage cloud efficiently with AWS, GCP, Azure

Bash and Cloud CLI: Manage cloud efficiently with AWS, GCP, Azure

Guide to managing cloud with Bash and CLI tools: AWS CLI, GCP CLI, Azure CLI — authentication, query JSON output, automate EC2/VM/app service.

DEVOPS BASH

Bash and Cloud CLI in DevOps

In the previous post, we learned how to call REST API with curl and jq. Now, we’ll apply those skills to cloud — where Bash combines with Cloud CLI to manage resources on AWS, GCP, Azure efficiently and automatically.

When managing cloud at scale, clicking through the web console is no longer feasible. Cloud CLI lets you start/stop instances, check status, snapshot, scale — all from the terminal. And Bash is the “glue” that connects these CLI tools into powerful automation scripts.

This post will guide you through setting up and using AWS CLI, GCP CLI, Azure CLI with Bash, querying JSON output, and building practical cloud management scripts.


Cloud CLI overview

Why use Cloud CLI?

MethodProsCons
Web ConsoleVisual, easy to useSlow, can’t automate
Cloud CLIFast, scriptable, automatedNeed to learn each cloud’s syntax
Direct APIMost flexibleComplex, manual auth handling

Cloud CLI is the sweet spot — powerful enough for automation, simple enough to learn quickly. And Bash is the perfect tool to orchestrate multiple CLI tools.

Installation and authentication

Each cloud provider has its own CLI tool:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# AWS CLI
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install

# GCP CLI
curl https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-linux-x86_64.tar.gz | tar xz
./google-cloud-sdk/install.sh

# Azure CLI
curl -sL https://aka.ms/InstallAzureCli | bash

After installation, each cloud requires authentication:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 # AWS: configure credentials
aws configure
# Enter Access Key ID, Secret Access Key, Region, Output format

# GCP: login via browser
gcloud auth login
gcloud config set project <your-project-id>

# Azure: login via browser
az login
az account set --subscription <your-subscription-id>

Query JSON output

All cloud CLI tools support JSON output — and combined with jq (from the previous post), you can filter and transform data easily.

AWS CLI

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# List all running EC2 instances
aws ec2 describe-instances \
    --query 'Reservations[].Instances[?State.Name==`running`].[InstanceId,InstanceType,PrivateIpAddress]' \
    --output table

# Use jq for more detailed filtering
aws ec2 describe-instances | jq -r '.Reservations[].Instances[] | select(.State.Name == "running") | [.InstanceId, .InstanceType, .PrivateIpAddress] | @tsv'

# Get latest AMI
aws ec2 describe-images --owners self \
    --query 'Images | sort_by(@, &CreationDate) | [-1].[ImageId,Name]' \
    --output text

GCP CLI

1
2
3
4
5
6
7
8
9
# List running VM instances
gcloud compute instances list --filter="status=RUNNING" \
    --format="table(name, zone, machineType.basename(), status)"

# Get external IP
gcloud compute instances list --format="value(name, networkInterfaces[0].accessConfigs[0].natIP)" | awk '{print $1": "$2}'

# Query disks
gcloud compute disks list --format="json" | jq '.[] | {name, sizeGb, status}'

Azure CLI

1
2
3
4
5
6
7
8
# List running VMs
az vm list --query "[?powerState=='VM running'].[name, resourceGroup, hardwareProfile.vmSize]" --output table

# Get public IP
az network public-ip list --query "[?ipConfiguration!=null].[name, ipAddress]" --output table

# Query app services
az webapp list --query "[].{name:name, state:state, plan:appServicePlan}" --output table

Hands-on: Cloud health checker

Script to check resource status across all 3 cloud providers:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
#!/usr/bin/env bash
# ==============================================================================
# Script : cloud-health-checker.sh
# Purpose: Check resource status on AWS, GCP, Azure
# ==============================================================================

set -euo pipefail

readonly LOG_FILE="/var/log/cloud-health.log"
readonly TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
readonly SLACK_WEBHOOK="${SLACK_WEBHOOK:-}"

# Cloud provider flags (enable/disable per config)
readonly CHECK_AWS="${CHECK_AWS:-false}"
readonly CHECK_GCP="${CHECK_GCP:-false}"
readonly CHECK_AZURE="${CHECK_AZURE:-false}"

# AWS config
readonly AWS_REGION="${AWS_REGION:-ap-southeast-1}"
export AWS_DEFAULT_OUTPUT="json"

# GCP config
readonly GCP_PROJECT="${GCP_PROJECT:-}"

# Azure config
readonly AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}"

# Logging
log() {
    local level="$1"
    local message="$2"
    echo "[$TIMESTAMP] [$level] $message" | tee -a "$LOG_FILE"
}

# Slack notification
notify_slack() {
    local message="$1"
    if [[ -n "$SLACK_WEBHOOK" ]]; then
        curl -s -X POST -H 'Content-type: application/json' \
            --data "{\"text\":\"${message}\"}" "$SLACK_WEBHOOK" >/dev/null 2>&1
    fi
}

# AWS: Check EC2 instances
check_aws_ec2() {
    log "INFO" "Checking AWS EC2 instances..."

    local instances
    instances=$(aws ec2 describe-instances --region "$AWS_REGION" 2>/dev/null || echo '{"Reservations":[]}')

    local running_count
    running_count=$(echo "$instances" | jq '[.Reservations[].Instances[] | select(.State.Name == "running")] | length')

    local stopped_count
    stopped_count=$(echo "$instances" | jq '[.Reservations[].Instances[] | select(.State.Name == "stopped")] | length')

    log "INFO" "AWS EC2: $running_count running, $stopped_count stopped"

    # Check unexpectedly stopped instances
    local stopped_instances
    stopped_instances=$(echo "$instances" | jq -r '.Reservations[].Instances[] | select(.State.Name == "stopped") | .InstanceId')

    if [[ -n "$stopped_instances" ]]; then
        while IFS= read -r instance_id; do
            log "WARN" "Instance $instance_id is stopped"
            notify_slack "AWS Alert: Instance $instance_id is stopped"
        done <<< "$stopped_instances"
    fi
}

# GCP: Check VM instances
check_gcp_compute() {
    log "INFO" "Checking GCP Compute instances..."

    local instances
    instances=$(gcloud compute instances list --project "$GCP_PROJECT" --format=json 2>/dev/null || echo '[]')

    local running_count
    running_count=$(echo "$instances" | jq '[.[] | select(.status == "RUNNING")] | length')

    local stopped_count
    stopped_count=$(echo "$instances" | jq '[.[] | select(.status == "TERMINATED")] | length')

    log "INFO" "GCP Compute: $running_count running, $stopped_count terminated"
}

# Azure: Check VMs
check_azure_vms() {
    log "INFO" "Checking Azure VMs..."

    az account set --subscription "$AZURE_SUBSCRIPTION" 2>/dev/null || true

    local vms
    vms=$(az vm list --query "[].{name:name, state:powerState, group:resourceGroup}" --output json 2>/dev/null || echo '[]')

    local running_count
    running_count=$(echo "$vms" | jq '[.[] | select(.state | contains("running"))] | length')

    local stopped_count
    stopped_count=$(echo "$vms" | jq '[.[] | select(.state | contains("deallocated"))] | length')

    log "INFO" "Azure VMs: $running_count running, $stopped_count deallocated"
}

# Main
main() {
    log "INFO" "=== Cloud Health Check Started ==="

    if [[ "$CHECK_AWS" == "true" ]]; then
        check_aws_ec2
    fi

    if [[ "$CHECK_GCP" == "true" ]]; then
        check_gcp_compute
    fi

    if [[ "$CHECK_AZURE" == "true" ]]; then
        check_azure_vms
    fi

    log "INFO" "=== Cloud Health Check Completed ==="
}

main "$@"

Running the script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Enable AWS and GCP checks
CHECK_AWS=true CHECK_GCP=true bash cloud-health-checker.sh

# Sample output
# [2026-09-05 10:00:00] [INFO] === Cloud Health Check Started ===
# [2026-09-05 10:00:01] [INFO] Checking AWS EC2 instances...
# [2026-09-05 10:00:02] [INFO] AWS EC2: 5 running, 1 stopped
# [2026-09-05 10:00:02] [WARN] Instance i-0987654321fedcba0 is stopped
# [2026-09-05 10:00:03] [INFO] Checking GCP Compute instances...
# [2026-09-05 10:00:04] [INFO] GCP Compute: 3 running, 0 terminated
# [2026-09-05 10:00:04] [INFO] === Cloud Health Check Completed ===

Common automation tasks

Snapshot EC2 instances (AWS)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/usr/bin/env bash
# Create snapshots for all EBS volumes attached to running instances

set -euo pipefail

REGION="${1:-ap-southeast-1}"

echo "Getting running instances in $REGION..."

# Get volume IDs from running instances
VOLUME_IDS=$(aws ec2 describe-instances --region "$REGION" \
    --query 'Reservations[].Instances[?State.Name==`running`].BlockDeviceMappings[].Ebs.VolumeId' \
    --output text | tr '\t' '\n' | sort -u)

for vol_id in $VOLUME_IDS; do
    echo "Creating snapshot for volume: $vol_id"
    aws ec2 create-snapshot \
        --volume-id "$vol_id" \
        --description "Auto-snapshot $(date +%Y-%m-%d)" \
        --tag-specifications "ResourceType=snapshot,Tags=[{Key=AutoBackup,Value=true}]" \
        --region "$REGION" > /dev/null

    echo "Snapshot created for $vol_id"
done

echo "All snapshots completed"

Auto-scale VMs based on CPU (GCP)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#!/usr/bin/env bash
# Auto-scale GCP instance group when CPU > 80%

set -euo pipefail

PROJECT="${GCP_PROJECT:-my-project}"
ZONE="${GCP_ZONE:-asia-southeast1-a}"
INSTANCE_GROUP="${INSTANCE_GROUP:-web-server-group}"
CPU_THRESHOLD=80

# Get CPU utilization
CPU_USAGE=$(gcloud monitoring time-series list \
    --filter='metric.type="compute.googleapis.com/instance/cpu/utilization"' \
    --interval-start-time="-PT5M" \
    --format="value(points[0].value.doubleValue)" 2>/dev/null | head -1 || echo "0")

# Convert to percentage
CPU_PERCENT=$(echo "$CPU_USAGE * 100" | bc 2>/dev/null | cut -d. -f1 || echo "0")

echo "Current CPU: ${CPU_PERCENT}%"

if (( CPU_PERCENT > CPU_THRESHOLD )); then
    echo "CPU above threshold, scaling up..."
    gcloud instance-groups managed set-autoscaling "$INSTANCE_GROUP" \
        --zone="$ZONE" \
        --project="$PROJECT" \
        --min-num-replicas=2 \
        --max-num-replicas=10 \
        --target-cpu-utilization=0.7
else
    echo "CPU normal, no action needed"
fi

Stop all VMs after work hours (Azure)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/usr/bin/env bash
# Stop all VMs in resource group (for dev/test environments)

set -euo pipefail

RESOURCE_GROUP="${1:?Usage: $0 <resource-group>}"

echo "Stopping all VMs in resource group: $RESOURCE_GROUP"

az vm stop --resource-group "$RESOURCE_GROUP" --no-wait

echo "Stop command sent. Check status with:"
echo "  az vm list -g $RESOURCE_GROUP --query '[].{name:name, state:powerState}' --output table"

Multi-cloud script pattern

When managing multiple clouds, an abstraction layer prevents vendor lock-in:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#!/usr/bin/env bash
# ==============================================================================
# Abstract layer for multi-cloud operations
# ==============================================================================

# Detect cloud provider from instance metadata
detect_cloud() {
    if curl -s --connect-timeout 1 http://169.254.169.254/latest/meta-data/ >/dev/null 2>&1; then
        echo "aws"
    elif curl -s --connect-timeout 1 -H "Metadata-Flavor: Google" http://metadata.google.internal/computeMetadata/v1/ >/dev/null 2>&1; then
        echo "gcp"
    elif curl -s --connect-timeout 1 http://169.254.169.254/metadata/instance?api-version=2021-02-01 >/dev/null 2>&1; then
        echo "azure"
    else
        echo "unknown"
    fi
}

# Get instance ID by cloud provider
get_instance_id() {
    local cloud="$1"
    case "$cloud" in
        aws)
            curl -s http://169.254.169.254/latest/meta-data/instance-id
            ;;
        gcp)
            curl -s -H "Metadata-Flavor: Google" http://metadata.google.internal/computeMetadata/v1/instance/name
            ;;
        azure)
            curl -s -H "Metadata: true" "http://169.254.169.254/metadata/instance/compute/name?api-version=2021-02-01" | tr -d '"'
            ;;
    esac
}

# Usage
CLOUD=$(detect_cloud)
INSTANCE_ID=$(get_instance_id "$CLOUD")
echo "Running on $CLOUD, instance: $INSTANCE_ID"

Implementation notes

  • Security: Always store credentials in profile files (~/.aws/credentials, ~/.config/gcloud/, ~/.azure/), never hardcode in scripts. Use IAM roles/service accounts when possible.
  • Rate limiting: Cloud APIs have rate limits. AWS: 100-200 requests/second, GCP: varies, Azure: varies. Add sleep or retry logic for intensive API calls.
  • Cost awareness: Some API calls incur costs (like describe-instances on AWS). Monitor AWS Cost Explorer or equivalent to avoid surprises.
  • Error handling: Cloud CLI can fail for many reasons (network, permission, throttle). Always check exit codes and log details.
  • Parallel execution: When managing many resources, use parallel execution to speed things up, but watch out for rate limits.

Conclusion

Bash and Cloud CLI is an incredibly powerful combination in DevOps. From querying JSON output with jq, automating snapshots/scaling, to multi-cloud management — everything can be done with just Bash scripts.

With a vendor-neutral pattern like above, you can write scripts that run on any cloud provider without lock-in.

In the next post, we’ll explore Bash and Security: how to secure scripts, validate input, manage secrets, and scan with shellcheck.

RESPONSES & DISCUSSION