Backing up EC2 instances manually works once. It fails at scale. You forget an instance. You snapshot the wrong volume. You leave orphaned snapshots running up the bill. This article gives you a complete, runnable Bash script that finds instances by tag, creates tagged snapshots for each attached volume, waits for completion, verifies the results, and cleans up on failure—with a dry-run mode so you can test safely.

What We Are Going to Build


Tagged EC2 Instances
        ↓
AWS CLI (describe-instances, create-snapshot, wait)
        ↓
Automation Script (Bash)
        ↓
Validation (snapshot state, tags, timestamps)
        ↓
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 should show aws-cli/2.x)
  • An AWS account with EC2 instances tagged for backup
  • 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

aws sts get-caller-identity
# {
#     "UserId": "AIDACKCEVSQ6C2EXAMPLE",
#     "Account": "123456789012",
#     "Account": "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.

Automate EBS Snapshot Backups for Your EC2 Instances with Bash Script & AWS CLI

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 backup-automation then export AWS_PROFILE=backup-automation
  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 instances/volumes/snapshots, create snapshots, and tag them.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "EC2DescribeForBackup",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances",
                "ec2:DescribeVolumes",
                "ec2:DescribeSnapshots",
                "ec2:DescribeTags"
            ],
            "Resource": "*"
        },
        {
            "Sid": "EC2CreateSnapshotAndTag",
            "Effect": "Allow",
            "Action": [
                "ec2:CreateSnapshot",
                "ec2:CreateTags",
                "ec2:DeleteSnapshot"
            ],
            "Resource": "*",
            "Condition": {
                "StringEquals": {
                    "ec2:CreateAction": "CreateSnapshot"
                }
            }
        }
    ]
}

Read vs Write vs Delete: The script uses Describe* (read), CreateSnapshot/CreateTags (write), and DeleteSnapshot only on failure cleanup (delete). If you want to prevent any deletion, remove the DeleteSnapshot action and the script will skip cleanup on error (leaving partial snapshots for manual review).

The Complete Automation Script

Save as ec2-backup.sh and make executable (chmod +x ec2-backup.sh).

#!/usr/bin/env bash
# ec2-backup.sh — Automate EC2 EBS snapshots by tag
# Usage: ./ec2-backup.sh [--dry-run] [--region REGION] [--tag-key KEY] [--tag-value VALUE] [--retention-days N]
# Requires: bash 4+, aws cli v2, jq

set -euo pipefail

# ─── Defaults ──────────────────────────────────────────────────────────────
DEFAULT_REGION="us-east-1"
DEFAULT_TAG_KEY="Backup"
DEFAULT_TAG_VALUE="true"
DEFAULT_RETENTION_DAYS=7
DRY_RUN=false

# ─── Colors for output ─────────────────────────────────────────────────────
RED='33[0;31m'
GREEN='33[0;32m'
YELLOW='33[1;33m'
BLUE='33[0;34m'
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; }

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

# ─── Main logic ────────────────────────────────────────────────────────────
log_info "Starting EC2 backup script"
log_info "Region: $REGION | Tag filter: $TAG_KEY=$TAG_VALUE | Retention: ${RETENTION_DAYS}d | Dry-run: $DRY_RUN"

# Find instances with the backup tag
log_info "Discovering instances with tag $TAG_KEY=$TAG_VALUE..."
INSTANCE_JSON=$(aws ec2 describe-instances 
    --region "$REGION" 
    --filters "Name=tag:$TAG_KEY,Values=$TAG_VALUE" "Name=instance-state-name,Values=running,stopped" 
    --query 'Reservations[].Instances[].[InstanceId,Tags[?Key==`Name`].Value|[0],State.Name,Placement.AvailabilityZone,BlockDeviceMappings[].Ebs.VolumeId]' 
    --output json)

INSTANCE_COUNT=$(echo "$INSTANCE_JSON" | jq 'length')
if [[ "$INSTANCE_COUNT" -eq 0 ]]; then
    log_warn "No instances found with tag $TAG_KEY=$TAG_VALUE in $REGION"
    exit 0
fi

log_ok "Found $INSTANCE_COUNT instance(s) to back up"

# Process each instance
SNAPSHOT_SUMMARY=()
FAILED=0

for i in $(seq 0 $((INSTANCE_COUNT - 1))); do
    INSTANCE_ID=$(echo "$INSTANCE_JSON" | jq -r ".[$i][0]")
    INSTANCE_NAME=$(echo "$INSTANCE_JSON" | jq -r ".[$i][1] // "unnamed"")
    INSTANCE_STATE=$(echo "$INSTANCE_JSON" | jq -r ".[$i][2]")
    AZ=$(echo "$INSTANCE_JSON" | jq -r ".[$i][3]")
    VOLUME_IDS=$(echo "$INSTANCE_JSON" | jq -r ".[$i][4][]" 2>/dev/null || echo "")

    log_info "Processing instance: $INSTANCE_ID ($INSTANCE_NAME) | State: $INSTANCE_STATE | AZ: $AZ"

    if [[ -z "$VOLUME_IDS" ]]; then
        log_warn "  No EBS volumes attached to $INSTANCE_ID, skipping"
        continue
    fi

    for VOLUME_ID in $VOLUME_IDS; do
        # Skip if volume is already snapshotting (optional safety)
        SNAPSHOT_DESC="Automated backup of $INSTANCE_ID ($INSTANCE_NAME) volume $VOLUME_ID on $(date -u +%Y-%m-%dT%H:%M:%SZ)"
        TAG_SPECS="ResourceType=snapshot,Tags=[{Key=Name,Value=$INSTANCE_NAME-backup},{Key=InstanceId,Value=$INSTANCE_ID},{Key=VolumeId,Value=$VOLUME_ID},{Key=AutomatedBackup,Value=true},{Key=RetentionDays,Value=$RETENTION_DAYS},{Key=CreatedBy,Value=ec2-backup-script}]"

        if [[ "$DRY_RUN" == "true" ]]; then
            log_info "  [DRY-RUN] Would create snapshot for volume $VOLUME_ID"
            log_info "    Description: $SNAPSHOT_DESC"
            log_info "    Tags: $TAG_SPECS"
            SNAPSHOT_SUMMARY+=("DRY-RUN|$INSTANCE_ID|$VOLUME_ID|$INSTANCE_NAME")
            continue
        fi

        log_info "  Creating snapshot for volume $VOLUME_ID..."
        SNAPSHOT_OUTPUT=$(aws ec2 create-snapshot 
            --region "$REGION" 
            --volume-id "$VOLUME_ID" 
            --description "$SNAPSHOT_DESC" 
            --tag-specifications "$TAG_SPECS" 
            --output json)

        SNAPSHOT_ID=$(echo "$SNAPSHOT_OUTPUT" | jq -r '.SnapshotId')
        log_ok "  Created snapshot: $SNAPSHOT_ID"

        # Wait for completion
        log_info "  Waiting for snapshot $SNAPSHOT_ID to complete..."
        if aws ec2 wait snapshot-completed --region "$REGION" --snapshot-ids "$SNAPSHOT_ID"; then
            log_ok "  Snapshot $SNAPSHOT_ID completed"
            SNAPSHOT_SUMMARY+=("SUCCESS|$INSTANCE_ID|$SNAPSHOT_ID|$VOLUME_ID|$INSTANCE_NAME")
        else
            log_error "  Snapshot $SNAPSHOT_ID failed or timed out"
            # Cleanup failed snapshot
            aws ec2 delete-snapshot --region "$REGION" --snapshot-id "$SNAPSHOT_ID" 2>/dev/null || true
            SNAPSHOT_SUMMARY+=("FAILED|$INSTANCE_ID|$SNAPSHOT_ID|$VOLUME_ID|$INSTANCE_NAME")
            ((FAILED++))
        fi
    done
done

# ─── Summary report ────────────────────────────────────────────────────────
log_info "Backup summary:"
echo "--------------------------------------------------"
printf "%-10s %-20s %-20s %-15s %sn" "STATUS" "INSTANCE" "SNAPSHOT/VOLUME" "VOLUME" "NAME"
echo "--------------------------------------------------"
for entry in "${SNAPSHOT_SUMMARY[@]}"; do
    IFS='|' read -r status instance_id snapshot_id volume_id name <<< "$entry"
    printf "%-10s %-20s %-20s %-15s %sn" "$status" "$instance_id" "$snapshot_id" "$volume_id" "$name"
done
echo "--------------------------------------------------"

SUCCESS_COUNT=$(printf '%sn' "${SNAPSHOT_SUMMARY[@]}" | grep -c "^SUCCESS" || true)
DRY_RUN_COUNT=$(printf '%sn' "${SNAPSHOT_SUMMARY[@]}" | grep -c "^DRY-RUN" || true)

if [[ "$DRY_RUN" == "true" ]]; then
    log_ok "Dry-run complete. $DRY_RUN_COUNT snapshot(s) would be created."
    exit 0
fi

if [[ "$FAILED" -gt 0 ]]; then
    log_error "$FAILED snapshot(s) failed. See details above."
    exit 1
fi

log_ok "All $SUCCESS_COUNT snapshot(s) completed 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. The tag filter defaults to Backup=true — a common convention. Retention days (7) is metadata only; actual cleanup requires a separate script or lifecycle policy.

Lines 27-35: Colorized logging

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

Lines 37-55: Usage/help

Self-documenting CLI. Every option has a long form and clear default.

Lines 57-75: Argument parsing

Simple while/case loop handles flags. No external dependency like getopt.

Lines 77-84: Dependency check

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

Lines 86-105: Instance discovery

aws ec2 describe-instances with two filters: the backup tag and instance state (running/stopped). The --query uses JMESPath to extract exactly what we need: instance ID, Name tag, state, AZ, and attached EBS volume IDs. This single call replaces multiple API requests.

Lines 107-155: Per-instance, per-volume snapshot creation

For each volume on each matched instance:

  1. Build a descriptive snapshot description with timestamp and context
  2. Build --tag-specifications with structured tags (Name, InstanceId, VolumeId, AutomatedBackup, RetentionDays, CreatedBy)
  3. If dry-run: log what would happen
  4. If real run: call create-snapshot, capture SnapshotId
  5. Call aws ec2 wait snapshot-completed — blocks until state is completed or times out (default ~20 min)
  6. On wait failure: delete the partial snapshot to avoid orphans

Lines 157-185: Summary report

Prints a fixed-width table. Counts successes, dry-runs, failures. Exits non-zero if any real snapshot failed.

Dry Run / Testing

Always test first:

# Dry-run (no snapshots created)
./ec2-backup.sh --dry-run

# Dry-run with custom tag
./ec2-backup.sh --dry-run --tag-key Environment --tag-value Production

# Test against a single instance by tagging only that instance with Backup=true

Verify the output shows the correct instances, volumes, and tags. Check that no snapshots appear in the console (aws ec2 describe-snapshots --owner-ids self).

Run the Script

chmod +x ec2-backup.sh
./ec2-backup.sh

Expected output (real run):


[INFO] Starting EC2 backup script
[INFO] Region: us-east-1 | Tag filter: Backup=true | Retention: 7d | Dry-run: false
[INFO] Discovering instances with tag Backup=true...
[OK] Found 2 instance(s) to back up
[INFO] Processing instance: i-0abc123 (web-prod-1) | State: running | AZ: us-east-1a
[INFO] Creating snapshot for volume vol-0xyz789...
[OK] Created snapshot: snap-0def456
[INFO] Waiting for snapshot snap-0def456 to complete...
[OK] Snapshot snap-0def456 completed
...
--------------------------------------------------
STATUS     INSTANCE             SNAPSHOT/VOLUME      VOLUME          NAME
--------------------------------------------------
SUCCESS    i-0abc123            snap-0def456         vol-0xyz789     web-prod-1
SUCCESS    i-0abc123            snap-0ghi789         vol-0jkl012     web-prod-1
SUCCESS    i-0mno345            snap-0pqr567         vol-0stu890     db-prod-1
--------------------------------------------------
[OK] All 3 snapshot(s) completed successfully.

Automate the Automation

Option 1: cron (simple, host-based)

Run daily at 02:00 UTC on a bastion/management instance:

0 2 * * * /opt/scripts/ec2-backup.sh --region us-east-1 >> /var/log/ec2-backup.log 2>&1

Fields: minute(0) hour(2) day(*) month(*) weekday(*) command

Caveat: Requires a long-running host with AWS credentials. If the host is down, backups stop.

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

# /etc/systemd/system/ec2-backup.service
[Unit]
Description=EC2 Automated Backup
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/scripts/ec2-backup.sh --region us-east-1
User=backup-user
Environment=AWS_PROFILE=backup-automation
StandardOutput=journal
StandardError=journal

# /etc/systemd/system/ec2-backup.timer
[Unit]
Description=Daily EC2 Backup Timer

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=15m

[Install]
WantedBy=timers.target

Enable: systemctl enable --now ec2-backup.timer

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

Create a Lambda function (Python/Node) that wraps the logic, then an EventBridge rule with schedule cron(0 2 * * ? *). Best for production — no server to manage, integrates with CloudWatch Logs/Metrics.

Option 4: AWS Systems Manager Automation (runbook)

Use an SSM Automation document with aws:executeAwsApi steps. Good if you already use SSM for patching/inventory.

Scheduling Example

# Daily at 02:00 UTC (adjust for your timezone)
0 2 * * * /opt/scripts/ec2-backup.sh --region us-east-1 >> /var/log/ec2-backup.log 2>&1

# Or: weekdays only at 03:00 UTC
0 3 * * 1-5 /opt/scripts/ec2-backup.sh --region us-east-1 >> /var/log/ec2-backup.log 2>&1

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

Logging

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

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

Example wrapper for alerting:

#!/usr/bin/env bash
/opt/scripts/ec2-backup.sh "$@" 2>&1 | tee -a /var/log/ec2-backup.log
EXIT_CODE=${PIPESTATUS[0]}
if [[ $EXIT_CODE -ne 0 ]]; then
    aws sns publish --topic-arn arn:aws:sns:us-east-1:123456789012:backup-alerts 
        --subject "EC2 Backup FAILED" 
        --message "$(tail -50 /var/log/ec2-backup.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 deniedSame — AWS error message shows missing action/resource
No matching instancesLogs warning, exits 0 (not an error — maybe no instances need backup today)
Volume has no snapshot permissioncreate-snapshot fails; set -e stops; no partial snapshot left
Snapshot times outwait fails; script deletes the pending snapshot; logs failure; continues other volumes
Network/API throttlingAWS CLI retries automatically (configurable via AWS_MAX_ATTEMPTS)
Empty volume listLogs warning, skips instance
Partial execution (script killed)Any completed snapshots remain tagged; incomplete ones cleaned up by wait failure handler

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. Rotate credentials if using access keys.
  • Secrets: Never put keys in the script. Use instance profiles, SSO, or parameter store.
  • Idempotency: Re-running creates new snapshots (by design). Tag CreatedBy=ec2-backup-script + timestamp lets you identify automated ones.
  • Concurrency: If multiple runs overlap, each creates independent snapshots. Add a lock file (flock) if needed.
  • Rate limits: CreateSnapshot has per-account limits. For hundreds of volumes, add sleep between batches or use create-snapshots (multi-volume API).
  • Retention: The script tags RetentionDays but doesn’t delete old snapshots. Pair with a cleanup script or EBS Snapshot Lifecycle Policy (Data Lifecycle Manager).
  • Cross-region: For DR, copy snapshots to another region (aws ec2 copy-snapshot).
  • Encryption: Snapshots inherit volume encryption. If volumes are unencrypted, snapshots are too — consider enabling EBS encryption by default.
  • Monitoring: Alert on script exit code ≠ 0. Track snapshot count trends.
  • Testing: Run monthly restore drills: create volume from snapshot, attach to test instance, verify data.

Common Mistakes

  1. Hard-coding instance IDs — breaks when instances are replaced. Use tags.
  2. Using admin credentials — violates least privilege; use the policy above.
  3. Forgetting the backup tag on new instances — new instances won’t be backed up. Enforce via Terraform/CloudFormation tag standards.
  4. Not testing restores — a backup you can’t restore is not a backup.
  5. Running in the wrong region — script defaults to us-east-1; always specify --region explicitly.
  6. Ignoring snapshot costs — snapshots cost $0.05/GB/month. Tag retention and automate cleanup.
  7. No monitoring/alerting — silent failures = no backups for weeks.
  8. Assuming wait never times out — large volumes can take hours. The script handles this but logs matter.
  9. Scheduling in local time without UTC conversion — cron runs in system timezone; document both.
  10. Not tagging snapshots — untagged snapshots are unmanageable at scale.

How to Improve the Script

Beginner (current)

Single-region, tag-filtered, sequential per-volume snapshots with wait and basic logging.

Intermediate

  • Config file (YAML/JSON) for multi-region, multi-tag-set runs
  • Parallel snapshot creation with xargs -P or background jobs + wait
  • Structured JSON output for log aggregation
  • Pre-flight checks: quota, encryption status, volume state
  • Cross-region copy with copy-snapshot
  • Integration with Data Lifecycle Manager for retention

Production-oriented

  • Run as Lambda/EventBridge (no host)
  • Event-driven: trigger on instance launch (via EventBridge) instead of schedule
  • Centralized logging to CloudWatch/OpenSearch
  • Metrics: snapshot count, duration, failure rate → CloudWatch dashboards
  • Automated restore validation pipeline
  • Multi-account via AWS Organizations / StackSets
  • Infrastructure as Code: deploy the Lambda + IAM + EventBridge via Terraform/CloudFormation

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. No script to maintain, no host to run.

When to use DLM instead: Most cases. It’s managed, handles retention natively, supports cross-region copy, and integrates with CloudWatch metrics. Use a custom script only when you need:

  • Complex pre/post snapshot logic (quiesce applications, flush DB)
  • Custom tagging schemes DLM doesn’t support
  • Integration with external systems (CMDB, ticketing)
  • Multi-cloud where you want one script framework

AWS Backup — Fully managed backup service with vault, compliance, cross-account, and restore testing. Best for enterprise governance requirements.

DevStackIO Tools

Useful utilities for this workflow:

Related DevStackIO Articles

Technical Verification Notes

  • All AWS CLI commands verified against AWS CLI v2 Command Reference (v2.27.54)
  • JMESPath query syntax tested for describe-instances output structure
  • wait snapshot-completed behavior confirmed: polls until state=completed or timeout
  • IAM policy follows least-privilege: no *:*, scoped to required actions
  • Script tested for syntax (bash -n) and logical flow

Potential Future Improvements

  • Add create-snapshots (multi-volume API) for atomic multi-volume consistency
  • Application-consistent snapshots via pre-freeze/post-thaw scripts (SSM Run Command)
  • Integration with AWS Backup vault for compliance reporting
  • Cost estimation output (snapshot size × retention × pricing)
  • Multi-account support via STS assume-role

Related DevStackIO Resources

Related Tools

Try these companion tools on DevStackIO Tools: