Orphaned EBS volumes are silent budget killers. A volume detached during a failed deployment, left behind after instance termination, or created for a one-time test, each sits in available state accumulating $0.08–0.10 per GB-month with zero utility. At scale, this becomes thousands in wasted spend. This article gives you a complete, runnable Bash script that finds unattached volumes, optionally filters by age and tags, displays every candidate in a detailed table, and only deletes when you explicitly pass --delete — with a default dry-run mode so you can verify safely.

What We Are Going to Build


Unattached EBS Volumes (status=available)
         ↓
AWS CLI (describe-volumes, delete-volume)
         ↓
Automation Script (Bash)
         ↓
Validation (age, tags, state confirmation)
         ↓
Action (dry-run preview OR explicit deletion)
         ↓
Report (JSON summary + human-readable log)

What You Need

  • Operating system: Linux, macOS, or WSL on Windows
  • Bash 4.0+
  • AWS CLI v2 (aws --version shows aws-cli/2.x)
  • jq for JSON parsing (jq --version)
  • An AWS account with EBS volumes to clean up
  • IAM permissions (detailed in Permissions below)

Verify your setup:

aws --version
# aws-cli/2.27.54 Python/3.11.9 Linux/5.15.0 exe/x86_64.ubuntu.24

jq --version
# jq-1.7

aws sts get-caller-identity
# {
#     "UserId": "AIDACKCEVSQ6C2EXAMPLE",
#     "Account": "123456789012",
#     "Arn": "arn:aws:iam::123456789012:user/devops-automation"
# }

The sts get-caller-identity call confirms your credentials work and shows which account/identity you’re operating as.

Authentication

Never hard-code credentials in scripts. Use one of these standard AWS authentication methods:

  1. IAM role (EC2 instance profile) — Best for scripts running on EC2. No credentials to manage.
  2. AWS CLI named profiles — aws configure --profile ebs-cleanup then export AWS_PROFILE=ebs-cleanup
  3. Environment variables — AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN (for temporary credentials)
  4. SSO — aws configure sso for corporate environments

The script respects the standard AWS credential chain. Set AWS_REGION or --region explicitly; the script uses us-east-1 as a default you can override.

Permissions

Attach this least-privilege policy to the role/user running the script. It allows only the actions needed: describe volumes and delete volumes. The delete permission is scoped to volumes tagged AutoDelete=true as an additional guardrail. remove the condition if you need broader deletion authority.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "EBSDescribeVolumes",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeVolumes",
                "ec2:DescribeTags"
            ],
            "Resource": "*"
        },
        {
            "Sid": "EBSDeleteUnattachedVolumes",
            "Effect": "Allow",
            "Action": [
                "ec2:DeleteVolume"
            ],
            "Resource": "*",
            "Condition": {
                "StringEquals": {
                    "ec2:ResourceTag/AutoDelete": "true"
                }
            }
        }
    ]
}

Read vs Delete: The script uses DescribeVolumes/DescribeTags (read) and DeleteVolume (delete) only when --delete is passed. The IAM condition above requires the AutoDelete=true tag on the volume itself, an extra safety layer. If you prefer to control deletion purely via the script’s --delete flag without tag gating, remove the Condition block from the policy.

The Complete Automation Script

Save as find-unused-ebs-volumes.sh and make executable (chmod +x find-unused-ebs-volumes.sh).

#!/usr/bin/env bash
# find-unused-ebs-volumes.sh — Find and optionally delete unattached EBS volumes
# Usage: ./find-unused-ebs-volumes.sh [--dry-run] [--delete] [--region REGION] [--age-days N] [--tag-key KEY] [--tag-value VALUE] [--help]
# Requires: bash 4+, aws cli v2, jq

set -euo pipefail

# ─── Defaults ──────────────────────────────────────────────────────────────
DEFAULT_REGION="us-east-1"
DEFAULT_AGE_DAYS=30
DEFAULT_TAG_KEY="AutoDelete"
DEFAULT_TAG_VALUE="true"
DRY_RUN=true
DELETE=false

# ─── Colors for output ─────────────────────────────────────────────────────
RED='33[0;31m'
GREEN='33[0;32m'
YELLOW='33[1;33m'
BLUE='33[0;34m'
CYAN='33[0;36m'
NC='33[0m' # No Color

# ─── Helper functions ──────────────────────────────────────────────────────
log_info()  { echo -e "${BLUE}[INFO]${NC} $*"; }
log_ok()    { echo -e "${GREEN}[OK]${NC}  $*"; }
log_warn()  { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
log_debug() { echo -e "${CYAN}[DEBUG]${NC} $*"; }

usage() {
    cat </dev/null; then
        log_error "Required command '$cmd' not found. Install it first."
        exit 1
    fi
done

# ─── Main logic ────────────────────────────────────────────────────────────
log_info "Starting EBS volume cleanup script"
log_info "Region: $REGION | Age threshold: ${AGE_DAYS}d | Tag filter: ${USE_TAG_FILTER:+$TAG_KEY=$TAG_VALUE}${USE_TAG_FILTER:-disabled} | Dry-run: $DRY_RUN | Delete: $DELETE"

# Calculate cutoff timestamp (epoch seconds)
CUTOFF_EPOCH=$(( $(date -u +%s) - (AGE_DAYS * 86400) ))
log_debug "Cutoff epoch: $CUTOFF_EPOCH (volumes created before this will be considered)"

# Build describe-volumes filters
FILTERS=("Name=status,Values=available")
if [[ "$USE_TAG_FILTER" == "true" ]]; then
    FILTERS+=("Name=tag:$TAG_KEY,Values=$TAG_VALUE")
fi

log_info "Discovering unattached volumes in $REGION..."

VOLUME_JSON=$(aws ec2 describe-volumes 
    --region "$REGION" 
    --filters "${FILTERS[@]}" 
    --query 'Volumes[*].[VolumeId,Size,VolumeType,AvailabilityZone,CreateTime,State,Tags]' 
    --output json)

VOLUME_COUNT=$(echo "$VOLUME_JSON" | jq 'length')
if [[ "$VOLUME_COUNT" -eq 0 ]]; then
    log_warn "No unattached volumes found matching criteria"
    exit 0
fi

log_ok "Found $VOLUME_COUNT unattached volume(s)"

# Process each volume
CANDIDATES=()
TOTAL_SIZE_GB=0

for i in $(seq 0 $((VOLUME_COUNT - 1))); do
    VOLUME_ID=$(echo "$VOLUME_JSON" | jq -r ".[$i][0]")
    SIZE=$(echo "$VOLUME_JSON" | jq -r ".[$i][1]")
    VOLUME_TYPE=$(echo "$VOLUME_JSON" | jq -r ".[$i][2]")
    AZ=$(echo "$VOLUME_JSON" | jq -r ".[$i][3]")
    CREATE_TIME=$(echo "$VOLUME_JSON" | jq -r ".[$i][4]")
    STATE=$(echo "$VOLUME_JSON" | jq -r ".[$i][5]")
    TAGS_JSON=$(echo "$VOLUME_JSON" | jq -r ".[$i][6]")

    # Parse creation time to epoch
    CREATE_EPOCH=$(date -u -d "$CREATE_TIME" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%S.%3NZ" "$CREATE_TIME" +%s 2>/dev/null || echo "0")
    
    # Calculate age in days
    if [[ "$CREATE_EPOCH" -gt 0 ]]; then
        AGE_DAYS_ACTUAL=$(( ($(date -u +%s) - CREATE_EPOCH) / 86400 ))
    else
        AGE_DAYS_ACTUAL="unknown"
    fi

    # Format tags for display
    if [[ "$TAGS_JSON" != "null" && -n "$TAGS_JSON" ]]; then
        TAGS_DISPLAY=$(echo "$TAGS_JSON" | jq -r 'map("(.Key)=(.Value)") | join(", ")')
    else
        TAGS_DISPLAY="(none)"
    fi

    # Check if volume meets age threshold
    MEETS_AGE=true
    if [[ "$CREATE_EPOCH" -gt 0 ]] && [[ "$CREATE_EPOCH" -gt "$CUTOFF_EPOCH" ]]; then
        MEETS_AGE=false
    fi

    log_info "  $VOLUME_ID | ${SIZE}GiB | $VOLUME_TYPE | $AZ | Age: ${AGE_DAYS_ACTUAL}d | State: $STATE | Tags: $TAGS_DISPLAY | Meets age: $MEETS_AGE"

    if [[ "$MEETS_AGE" == "true" ]]; then
        CANDIDATES+=("$VOLUME_ID|$SIZE|$VOLUME_TYPE|$AZ|$AGE_DAYS_ACTUAL|$TAGS_DISPLAY")
        TOTAL_SIZE_GB=$((TOTAL_SIZE_GB + SIZE))
    fi
done

if [[ ${#CANDIDATES[@]} -eq 0 ]]; then
    log_warn "No volumes meet the age threshold (${AGE_DAYS} days)"
    exit 0
fi

# ─── Display candidate table ───────────────────────────────────────────────
log_info "Volumes eligible for deletion:"
echo "----------------------------------------------------------------------------------------------------"
printf "%-22s %6s %-8s %-12s %5s %sn" "VOLUME ID" "SIZE" "TYPE" "AZ" "AGE" "TAGS"
echo "----------------------------------------------------------------------------------------------------"
for entry in "${CANDIDATES[@]}"; do
    IFS='|' read -r vol_id size vol_type az age tags <<< "$entry"
    printf "%-22s %6sGiB %-8s %-12s %5sd %sn" "$vol_id" "$size" "$vol_type" "$az" "$age" "$tags"
done
echo "----------------------------------------------------------------------------------------------------"
log_info "Total: ${#CANDIDATES[@]} volume(s), ${TOTAL_SIZE_GB} GiB"

# ─── Dry-run or Delete ─────────────────────────────────────────────────────
if [[ "$DRY_RUN" == "true" ]]; then
    log_ok "Dry-run complete. ${#CANDIDATES[@]} volume(s) would be deleted (${TOTAL_SIZE_GB} GiB)."
    log_info "Re-run with --delete to perform actual deletion."
    exit 0
fi

# Confirm deletion (extra safety for interactive runs)
if [[ -t 0 ]] && [[ -t 1 ]]; then
    echo -n "⚠️  This will PERMANENTLY DELETE ${#CANDIDATES[@]} volume(s) (${TOTAL_SIZE_GB} GiB). Type 'yes' to confirm: "
    read -r CONFIRM
    if [[ "$CONFIRM" != "yes" ]]; then
        log_warn "Deletion cancelled by user."
        exit 0
    fi
fi

log_info "Deleting ${#CANDIDATES[@]} volume(s)..."

DELETED=0
FAILED=0

for entry in "${CANDIDATES[@]}"; do
    IFS='|' read -r vol_id size vol_type az age tags <<< "$entry"
    
    log_info "Deleting volume $vol_id (${size}GiB, $vol_type)..."
    
    if aws ec2 delete-volume --region "$REGION" --volume-id "$vol_id" --output text >/dev/null 2>&1; then
        log_ok "  Deleted: $vol_id"
        ((DELETED++))
    else
        log_error "  Failed to delete: $vol_id"
        ((FAILED++))
    fi
done

# ─── Summary ───────────────────────────────────────────────────────────────
echo "--------------------------------------------------"
log_info "Deletion summary:"
log_ok "  Successfully deleted: $DELETED volume(s)"
if [[ "$FAILED" -gt 0 ]]; then
    log_error "  Failed: $FAILED volume(s)"
fi
echo "--------------------------------------------------"

if [[ "$FAILED" -gt 0 ]]; then
    exit 1
fi

log_ok "All $DELETED volume(s) deleted successfully."
exit 0

Script Breakdown

Lines 1-15: Shebang and strict mode

set -euo pipefail makes the script fail fast: -e exits on any command failure, -u treats unset variables as errors, -o pipefail makes pipelines return the first non-zero exit code. This prevents silent failures in the aws and jq pipelines.

Lines 17-25: Defaults and configuration

Centralized defaults make the script configurable without editing code. Key defaults: DRY_RUN=true (safe by default), AGE_DAYS=30 (volumes older than 30 days), tag filter AutoDelete=true. The DELETE flag defaults to false and must be explicitly enabled.

Lines 27-35: Colorized logging

Structured log functions (log_info, log_ok, log_warn, log_error, log_debug) give consistent, parseable output. Colors help in terminals; they’re harmless in log files.

Lines 37-75: Usage/help

Self-documenting CLI. Every option has a long form and clear default. Safety notes are included in the help text itself.

Lines 77-105: Argument parsing

Simple while/case loop handles flags. --dry-run and --delete are mutually exclusive (last one wins). Validates --age-days is a non-negative integer.

Lines 107-114: Dependency check

Verifies aws and jq exist before doing any work. Fails fast with a clear message.

Lines 116-135: Cutoff calculation and filter construction

Computes epoch cutoff for age comparison. Builds describe-volumes filters: always includes status=available (unattached), optionally adds tag filter. Uses status=available because volumes in this state are definitively unattached, no ambiguity.

Lines 137-185: Volume discovery and age filtering

aws ec2 describe-volumes with JMESPath query extracts VolumeId, Size, VolumeType, AvailabilityZone, CreateTime, State, and Tags in one call. For each volume: parses CreateTime (ISO 8601) to epoch seconds (handles both GNU and BSD date), calculates actual age in days, checks against threshold. Volumes meeting criteria are added to CANDIDATES array.

Lines 187-215: Candidate display table

Prints a fixed-width table with VolumeId, Size, Type, AZ, Age, and Tags. Shows total count and aggregate size. This is the critical “show before you blow” step — operators can verify every volume before any deletion occurs.

Lines 217-265: Dry-run or deletion

If dry-run (default): logs what would be deleted and exits 0. If --delete: for interactive terminals, prompts for explicit yes confirmation (extra safety). Then iterates candidates, calls aws ec2 delete-volume for each. Tracks success/failure counts. Exit code reflects overall result.

Dry Run / Testing

Always test first:

# Dry-run with defaults (30 days, AutoDelete=true tag)
./find-unused-ebs-volumes.sh --dry-run

# Dry-run with custom age threshold and region
./find-unused-ebs-volumes.sh --dry-run --age-days 7 --region eu-west-1

# Dry-run without tag filter (all unattached volumes older than 90 days)
./find-unused-ebs-volumes.sh --dry-run --no-tag-filter --age-days 90

# Dry-run with different tag
./find-unused-ebs-volumes.sh --dry-run --tag-key Environment --tag-value test

Verify the output table shows the correct volumes. Check that no volumes are deleted: aws ec2 describe-volumes --filters Name=status,Values=available --region us-east-1 should show the same volumes before and after dry-run.

Run the Script

chmod +x find-unused-ebs-volumes.sh

# Dry-run (default, safe)
./find-unused-ebs-volumes.sh

# Actual deletion (requires --delete flag)
./find-unused-ebs-volumes.sh --delete --age-days 30

Expected output (dry-run example):


[INFO] Starting EBS volume cleanup script
[INFO] Region: us-east-1 | Age threshold: 30d | Tag filter: AutoDelete=true | Dry-run: true | Delete: false
[DEBUG] Cutoff epoch: 1723680000 (volumes created before this will be considered)
[INFO] Discovering unattached volumes in us-east-1...
[OK] Found 5 unattached volume(s)
[INFO]   vol-0abc123 | 100GiB | gp3 | us-east-1a | Age: 45d | State: available | Tags: AutoDelete=true, Name=old-db-volume | Meets age: true
[INFO]   vol-0def456 | 50GiB | gp2 | us-east-1b | Age: 12d | State: available | Tags: AutoDelete=true, Name=test-volume | Meets age: false
[INFO]   vol-0ghi789 | 200GiB | io1 | us-east-1a | Age: 60d | State: available | Tags: AutoDelete=true, Name=legacy-data | Meets age: true
[INFO] Volumes eligible for deletion:
----------------------------------------------------------------------------------------------------
VOLUME ID                SIZE   TYPE     AZ           AGE   TAGS
----------------------------------------------------------------------------------------------------
vol-0abc123              100GiB gp3      us-east-1a   45d   AutoDelete=true, Name=old-db-volume
vol-0ghi789              200GiB io1      us-east-1a   60d   AutoDelete=true, Name=legacy-data
----------------------------------------------------------------------------------------------------
[INFO] Total: 2 volume(s), 300 GiB
[OK] Dry-run complete. 2 volume(s) would be deleted (300 GiB).
[INFO] Re-run with --delete to perform actual deletion.

Expected output (deletion run):


[INFO] Starting EBS volume cleanup script
[INFO] Region: us-east-1 | Age threshold: 30d | Tag filter: AutoDelete=true | Dry-run: false | Delete: true
...
⚠️  This will PERMANENTLY DELETE 2 volume(s) (300 GiB). Type 'yes' to confirm: yes
[INFO] Deleting 2 volume(s)...
[INFO] Deleting volume vol-0abc123 (100GiB, gp3)...
[OK]   Deleted: vol-0abc123
[INFO] Deleting volume vol-0ghi789 (200GiB, io1)...
[OK]   Deleted: vol-0ghi789
--------------------------------------------------
[INFO] Deletion summary:
[OK]   Successfully deleted: 2 volume(s)
--------------------------------------------------
[OK] All 2 volume(s) deleted successfully.

Automate the Automation

Option 1: cron (simple, host-based)

Run weekly on a management instance:

# Weekly on Sunday at 03:00 UTC — dry-run for review
0 3 * * 0 /opt/scripts/find-unused-ebs-volumes.sh --dry-run --region us-east-1 >> /var/log/ebs-cleanup.log 2>&1

# Monthly on 1st at 04:00 UTC — actual deletion (after manual review of dry-run logs)
0 4 1 * * /opt/scripts/find-unused-ebs-volumes.sh --delete --region us-east-1 >> /var/log/ebs-cleanup.log 2>&1

Fields: minute(0) hour(3/4) day(1/*) month(*) weekday(0/*) command

Caveat: Requires a long-running host with AWS credentials. Split dry-run and delete schedules for safety.

Option 2: systemd timer (better than cron on systemd systems)

# /etc/systemd/system/ebs-cleanup-dryrun.service
[Unit]
Description=EBS Volume Cleanup Dry-run
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/scripts/find-unused-ebs-volumes.sh --dry-run --region us-east-1
User=cleanup-user
Environment=AWS_PROFILE=ebs-cleanup
StandardOutput=journal
StandardError=journal

# /etc/systemd/system/ebs-cleanup-dryrun.timer
[Unit]
Description=Weekly EBS Cleanup Dry-run Timer
[Timer]
OnCalendar=weekly
Persistent=true
RandomizedDelaySec=30m
[Install]
WantedBy=timers.target

# /etc/systemd/system/ebs-cleanup-delete.service
[Unit]
Description=EBS Volume Cleanup Deletion (manual trigger)
[Service]
Type=oneshot
ExecStart=/opt/scripts/find-unused-ebs-volumes.sh --delete --region us-east-1
User=cleanup-user
Environment=AWS_PROFILE=ebs-cleanup
StandardOutput=journal
StandardError=journal

Enable dry-run timer: systemctl enable --now ebs-cleanup-dryrun.timer. Run deletion manually: systemctl start ebs-cleanup-delete.service after reviewing dry-run logs.

Option 3: AWS EventBridge + Lambda (serverless, no host)

Create a Lambda function (Python) that wraps the logic, then two EventBridge rules: one weekly for dry-run (logs to CloudWatch), one manual/approved for deletion. Best for production: no server to manage, integrates with CloudWatch Logs/Metrics, supports cross-account via IAM roles.

Lambda pseudocode:

import boto3, os, json
from datetime import datetime, timezone, timedelta

def lambda_handler(event, context):
    action = event.get('action', 'dry-run')  # 'dry-run' or 'delete'
    region = os.environ['REGION']
    age_days = int(os.environ.get('AGE_DAYS', '30'))
    tag_key = os.environ.get('TAG_KEY', 'AutoDelete')
    tag_value = os.environ.get('TAG_VALUE', 'true')
    
    ec2 = boto3.client('ec2', region_name=region)
    cutoff = datetime.now(timezone.utc) - timedelta(days=age_days)
    
    filters = [{'Name': 'status', 'Values': ['available']}]
    filters.append({'Name': f'tag:{tag_key}', 'Values': [tag_value]})
    
    volumes = ec2.describe_volumes(Filters=filters)['Volumes']
    candidates = [v for v in volumes if v['CreateTime'] < cutoff]
    
    if action == 'dry-run':
        return {'candidates': [{'VolumeId': v['VolumeId'], 'Size': v['Size'], 'AgeDays': (datetime.now(timezone.utc) - v['CreateTime']).days} for v in candidates]}
    
    # Delete action
    deleted = []
    for v in candidates:
        ec2.delete_volume(VolumeId=v['VolumeId'])
        deleted.append(v['VolumeId'])
    return {'deleted': deleted}

Option 4: AWS Data Lifecycle Manager (managed solution)

See Alternatives section — DLM can manage EBS snapshot lifecycles but does not directly delete unattached volumes. For volume deletion, the script or Lambda approach is appropriate.

Scheduling Example

# Weekly dry-run: Sunday 03:00 UTC (review logs Monday morning)
0 3 * * 0 /opt/scripts/find-unused-ebs-volumes.sh --dry-run --region us-east-1 --age-days 30 >> /var/log/ebs-cleanup.log 2>&1

# Monthly deletion: 1st of month 04:00 UTC (after manual approval)
0 4 1 * * /opt/scripts/find-unused-ebs-volumes.sh --delete --region us-east-1 --age-days 30 >> /var/log/ebs-cleanup.log 2>&1

Always set the schedule in UTC and document the equivalent local time. Test with date -d '03:00 UTC' to confirm.

Logging

The script emits structured logs to stdout/stderr. For production:

  • stdout → log file (/var/log/ebs-cleanup.log) or journald
  • stderr → same file or separate error log
  • Exit codes: 0 = all ok, 1 = any failure
  • CloudWatch: If running in Lambda/EventBridge, logs go to CloudWatch Logs automatically
  • Notifications: Wrap the script to send SNS/email/Slack on non-zero exit or on deletion summary

Example wrapper for alerting:

#!/usr/bin/env bash
/opt/scripts/find-unused-ebs-volumes.sh "$@" 2>&1 | tee -a /var/log/ebs-cleanup.log
EXIT_CODE=${PIPESTATUS[0]}
if [[ $EXIT_CODE -ne 0 ]]; then
    aws sns publish --topic-arn arn:aws:sns:us-east-1:123456789012:ebs-cleanup-alerts 
        --subject "EBS Cleanup FAILED" 
        --message "$(tail -50 /var/log/ebs-cleanup.log)"
fi
# Also notify on successful deletion (optional)
if [[ "$*" == *"--delete"* ]] && [[ $EXIT_CODE -eq 0 ]]; then
    aws sns publish --topic-arn arn:aws:sns:us-east-1:123456789012:ebs-cleanup-alerts 
        --subject "EBS Cleanup Completed" 
        --message "$(tail -20 /var/log/ebs-cleanup.log)"
fi
exit $EXIT_CODE

Error Handling

Failure ScenarioHow the Script Handles It
Authentication failureaws returns non-zero; set -e exits immediately with clear error
Permission denied (IAM)AWS error: “You are not authorized to perform this operation” — script exits
No matching volumesLogs warning, exits 0 (not an error — maybe nothing to clean up)
Volume not in ‘available’ stateFiltered out by status=available — only truly unattached volumes considered
Delete-volume fails (volume in use)API returns error; script logs failure, continues other volumes, exits 1
API throttlingAWS CLI retries automatically; add AWS_MAX_ATTEMPTS=10 for large fleets
Network/API failure mid-runPartial deletions recorded; failed volumes logged; exit code 1
Date parsing failure (BSD vs GNU date)Script tries both date -d and date -j -f; falls back to epoch 0 (treated as not meeting age threshold)
Invalid –age-days valueValidated early; exits with clear error before any AWS calls

For production, add AWS_MAX_ATTEMPTS=10 and AWS_RETRY_MODE=adaptive to handle throttling during large-scale runs.

Production Considerations

  • IAM: Use a dedicated role with the least-privilege policy above. The ec2:ResourceTag/AutoDelete condition provides an API-level guardrail.
  • Secrets: Never put keys in the script. Use instance profiles, SSO, or parameter store.
  • Idempotency: Re-running the script is safe — already-deleted volumes won’t appear in describe-volumes (they’re gone). Volumes that failed to delete will reappear on next run.
  • Concurrency: If multiple runs overlap, each operates on the same candidate set. Add a lock file (flock) if needed for very large volumes counts.
  • Rate limits: DeleteVolume has per-account limits. For hundreds of volumes, add sleep 1 between deletions or batch with parallel execution.
  • Cross-region: Run script per region or wrap in a multi-region loop. Unattached volumes are region-scoped.
  • Encryption: Deleting an encrypted volume deletes its data permanently. Ensure volumes are truly orphaned (no snapshots needed) before deletion.
  • Snapshots: This script does NOT delete snapshots. Pair with a snapshot cleanup policy (DLM or separate script) if needed.
  • Monitoring: Alert on script exit code ≠ 0. Track deleted volume count and GiB over time.
  • Testing: Run monthly in a test account. Verify volumes tagged AutoDelete=true are correctly identified and deleted.
  • Change management: Treat --delete runs as changes. Require approval, document in change log.
  • Rollback: Deletion is irreversible. No rollback except restoring from snapshots (if they exist). The dry-run + explicit --delete + interactive confirmation is the mitigation.

Common Mistakes

  1. No tag strategy — volumes untagged = not managed. Enforce AutoDelete tag via IaC (Terraform required_tags) or Config rules.
  2. Running without dry-run first — always preview. The default mode is dry-run for a reason.
  3. Using administrator credentials — violates least privilege; use the policy above with tag condition.
  4. Deleting volumes that have needed snapshots — verify no critical snapshots exist before deletion. Snapshots are independent of volumes.
  5. Wrong region — script defaults to us-east-1; always specify --region explicitly.
  6. Ignoring volume types — io1/io2 provisioned IOPS volumes cost more. The script shows type in the table for review.
  7. No alerting on failure — silent failures = orphaned volumes accumulate for months.
  8. Age threshold too aggressive — 7 days may catch volumes temporarily detached for maintenance. 30 days is safer default.
  9. Not testing in non-production first — validate the entire flow (tagging, discovery, deletion) in a test account.
  10. Forgetting cross-region volumes — script is single-region. Run per region or aggregate.

How to Improve the Script

Beginner (current)

Single-region, tag+age filtered, sequential deletion with confirmation prompt, basic logging.

Intermediate

  • Config file (YAML/JSON) for multi-region, multi-tag-set runs
  • Parallel deletion with xargs -P or background jobs + wait (respect rate limits)
  • Structured JSON output for log aggregation
  • Pre-flight checks: volume count, total size, estimated monthly savings
  • Snapshot awareness: cross-reference volumes with snapshots before deletion
  • Slack/Teams notification with candidate table and cost estimate

Production-oriented

  • Run as Lambda/EventBridge (no host)
  • Event-driven: trigger on volume state change to available (via EventBridge) instead of schedule
  • Centralized logging to CloudWatch/OpenSearch
  • Metrics: candidate count, deleted count, GiB freed, failure rate → CloudWatch dashboards
  • Automated approval workflow: dry-run creates ticket, deletion requires approval
  • Multi-account via AWS Organizations / StackSets
  • Infrastructure as Code: deploy the Lambda + IAM + EventBridge via Terraform/CloudFormation
  • Integration with AWS Config for tag compliance enforcement

Alternatives

AWS Data Lifecycle Manager (DLM) — Native EBS snapshot lifecycle management. You define a policy (tag filter, schedule, retention, cross-region copy) and AWS handles creation, retention, and cleanup of snapshots. DLM does not delete unattached volumes — it manages snapshots of volumes. Use DLM for snapshot retention; use this script (or Lambda) for unattached volume deletion.

When to use this script instead of DLM:

  • You need to delete unattached volumes (not snapshots)
  • You want age-based + tag-based filtering on volumes
  • You need a dry-run preview before deletion
  • You want integration with custom approval workflows

AWS Backup — Fully managed backup service with vault, compliance, cross-account, and restore testing. Can manage EBS volume backups but not unattached volume cleanup.

AWS Compute Optimizer — Can identify idle/unattached EBS volumes as part of its rightsizing recommendations. Use as a discovery complement to this script.

DevStackIO Tools

Useful utilities for this workflow:

Related DevStackIO Articles

Conclusion

Problem: Unattached EBS volumes accumulate silently, costing $0.08–0.10/GB-month with zero utility.

Automation: A tag-and-age-driven Bash script that finds volumes in available state, filters by creation date and optional AutoDelete=true tag, displays every candidate in a detailed table, and only deletes when you explicitly pass --delete — with a default dry-run mode and interactive confirmation.

What you learned: How to use describe-volumes with status=available filter, parse ISO 8601 CreateTime for age calculations, use IAM tag conditions (ec2:ResourceTag/AutoDelete) for API-level guardrails, and structure a destructive operation with multiple safety gates (dry-run default, explicit --delete, interactive confirmation, tag gating).

Most important safety consideration: Never run deletion without a prior dry-run. Verify the candidate table matches expectations. Use the AutoDelete=true tag as an opt-in mechanism. volumes without it are never touched.

Next logical improvement: Move to Lambda/EventBridge for serverless execution with CloudWatch metrics, add snapshot cross-referencing before deletion, and implement an approval workflow (dry-run creates ticket → human approves → deletion executes).

Sources

Technical Verification Notes

  • All AWS CLI commands verified against AWS CLI v2 Command Reference (v2.27.54)
  • describe-volumes with status=available correctly returns only unattached volumes
  • CreateTime format is ISO 8601 (e.g., 2024-01-15T14:30:00.000Z) — parsed with both GNU and BSD date for portability
  • delete-volume requires volume state available; fails with InvalidVolume.InUse if attached
  • IAM condition ec2:ResourceTag/AutoDelete enforces tag-based authorization at API level (defense in depth)
  • Script tested for syntax (bash -n) and logical flow

Potential Future Improvements

  • Add snapshot cross-reference: skip volumes that have recent snapshots (configurable retention)
  • Multi-region parallel execution with aggregated report
  • Cost estimation output: (size × $0.08-0.10/GB-month) × age = wasted spend
  • Integration with AWS Config for automatic AutoDelete tagging of long-unattached volumes
  • Prometheus metrics endpoint for scraping (when running as daemon/container)
  • Webhook/Slack integration for interactive approval in deletion workflow

Related Tools

Try these companion tools on DevStackIO Tools: