Cloud waste is invisible until the bill arrives. EC2 instances left running nights and weekends in dev/test environments can cost thousands per month for no reason. This article gives you a complete, runnable Bash script that finds instances by tag, stops them on a schedule, starts them on another, verifies the state transitions, and includes a dry-run mode so you can test safely without touching production.

What We Are Going to Build

Tagged EC2 Instances (Environment=dev|test|staging)
        ↓
AWS CLI (describe-instances, stop-instances, start-instances, wait)
        ↓
Automation Script (Bash)
        ↓
Validation (state transitions, instance health)
        ↓
Report (affected instances, cost estimate, dry-run preview)

What You Need

  • Operating system: Linux, macOS, or WSL on Windows
  • Bash 4.0+
  • AWS CLI v2 (aws --version shows aws-cli/2.x)
  • An AWS account with EC2 instances tagged for scheduling
  • 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",
#     "Arn": "arn:aws:iam::123456789012:user/devops-automation"
# }

Authentication

Use standard AWS authentication — never hard-code credentials:

  1. IAM role (EC2 instance profile) — Best for scripts running on EC2
  2. AWS CLI named profiles — aws configure --profile ec2-scheduler
  3. Environment variables — AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN
  4. SSO — aws configure sso for corporate environments

The script respects the standard AWS credential chain. Set AWS_REGION explicitly or use --region flag.

Permissions

Attach this least-privilege policy. It allows discovering instances by tag and starting/stopping them — but not terminating, rebooting, or modifying attributes.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "EC2DescribeForScheduling",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances",
                "ec2:DescribeTags",
                "ec2:DescribeInstanceStatus"
            ],
            "Resource": "*"
        },
        {
            "Sid": "EC2StartStopOnly",
            "Effect": "Allow",
            "Action": [
                "ec2:StartInstances",
                "ec2:StopInstances"
            ],
            "Resource": "*",
            "Condition": {
                "StringEquals": {
                    "ec2:ResourceTag/Environment": ["dev", "test", "staging"]
                }
            }
        }
    ]
}

Key safeguard: The condition ec2:ResourceTag/Environment restricts start/stop actions to instances tagged Environment=dev, test, or staging. Production instances (tagged Environment=prod or production) are excluded at the IAM level — even if the script logic has a bug, it cannot affect them.

The Complete Automation Script

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

#!/usr/bin/env bash
# ec2-scheduler.sh — Start/stop EC2 instances on a schedule by tag
# Usage: ./ec2-scheduler.sh --action start|stop [--dry-run] [--region REGION] [--tag-key KEY] [--tag-values VAL1,VAL2...] [--exclude-tags KEY=VAL...]
# Requires: bash 4+, aws cli v2, jq

set -euo pipefail

# ─── Defaults ──────────────────────────────────────────────────────────────
DEFAULT_REGION="us-east-1"
DEFAULT_TAG_KEY="Environment"
DEFAULT_TAG_VALUES=("dev" "test" "staging")
ACTION=""
DRY_RUN=false
EXCLUDE_TAGS=()

# ─── Colors ────────────────────────────────────────────────────────────────
RED='33[0;31m'
GREEN='33[0;32m'
YELLOW='33[1;33m'
BLUE='33[0;34m'
NC='33[0m'

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 <<EOF
Usage: $0 --action start|stop [OPTIONS]

Start or stop EC2 instances matching tag filters.

Required:
  --action start|stop    Action to perform

Options:
  --dry-run              Show what would be done without making changes
  --region REGION        AWS region (default: $DEFAULT_REGION)
  --tag-key KEY          Tag key to filter (default: $DEFAULT_TAG_KEY)
  --tag-values V1,V2     Comma-separated tag values (default: dev,test,staging)
  --exclude-tags K=V     Tag key=value pairs to exclude (repeatable)
  --help                 Show this help

Examples:
  $0 --action stop --dry-run
  $0 --action start --region eu-west-1
  $0 --action stop --tag-key Environment --tag-values dev,test --exclude-tags Owner=team-a
EOF
}

# ─── Parse arguments ───────────────────────────────────────────────────────
REGION="$DEFAULT_REGION"
TAG_KEY="$DEFAULT_TAG_KEY"
TAG_VALUES=("${DEFAULT_TAG_VALUES[@]}")

while [[ $# -gt 0 ]]; do
    case $1 in
        --action) ACTION="$2"; shift 2 ;;
        --dry-run) DRY_RUN=true; shift ;;
        --region) REGION="$2"; shift 2 ;;
        --tag-key) TAG_KEY="$2"; shift 2 ;;
        --tag-values) IFS=',' read -ra TAG_VALUES <<< "$2"; shift 2 ;;
        --exclude-tags) EXCLUDE_TAGS+=("$2"); shift 2 ;;
        --help) usage; exit 0 ;;
        *) log_error "Unknown option: $1"; usage; exit 1 ;;
    esac
done

# ─── Validate ──────────────────────────────────────────────────────────────
if [[ -z "$ACTION" ]]; then
    log_error "--action is required (start or stop)"
    usage
    exit 1
fi

if [[ "$ACTION" != "start" && "$ACTION" != "stop" ]]; then
    log_error "Invalid action: $ACTION. Must be 'start' or 'stop'"
    exit 1
fi

for cmd in aws jq; do
    if ! command -v "$cmd" &>/dev/null; then
        log_error "Required command '$cmd' not found"
        exit 1
    fi
done

# ─── Build tag filter ──────────────────────────────────────────────────────
TAG_FILTER_ARGS=()
for val in "${TAG_VALUES[@]}"; do
    TAG_FILTER_ARGS+=("Name=tag:$TAG_KEY,Values=$val")
done

# Add exclude filters
for excl in "${EXCLUDE_TAGS[@]}"; do
    excl_key="${excl%%=*}"
    excl_val="${excl#*=}"
    TAG_FILTER_ARGS+=("Name=tag:$excl_key,Values=$excl_val")
done

log_info "Starting EC2 scheduler"
log_info "Action: $ACTION | Region: $REGION | Tag filter: $TAG_KEY in [${TAG_VALUES[*]}] | Dry-run: $DRY_RUN"
[[ ${#EXCLUDE_TAGS[@]} -gt 0 ]] && log_info "Excluding tags: ${EXCLUDE_TAGS[*]}"

# ─── Discover instances ────────────────────────────────────────────────────
log_info "Discovering instances..."
INSTANCE_JSON=$(aws ec2 describe-instances 
    --region "$REGION" 
    --filters "${TAG_FILTER_ARGS[@]}" "Name=instance-state-name,Values=running,stopped,stopping,pending" 
    --query 'Reservations[].Instances[].[InstanceId,Tags[?Key==`Name`].Value|[0],State.Name,InstanceType,Placement.AvailabilityZone,Tags]' 
    --output json)

INSTANCE_COUNT=$(echo "$INSTANCE_JSON" | jq 'length')
if [[ "$INSTANCE_COUNT" -eq 0 ]]; then
    log_warn "No instances found matching criteria"
    exit 0
fi

log_ok "Found $INSTANCE_COUNT instance(s)"

# ─── Filter by desired state transition ────────────────────────────────────
TARGET_INSTANCES=()
SKIPPED=()

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"")
    CURRENT_STATE=$(echo "$INSTANCE_JSON" | jq -r ".[$i][2]")
    INSTANCE_TYPE=$(echo "$INSTANCE_JSON" | jq -r ".[$i][3]")
    AZ=$(echo "$INSTANCE_JSON" | jq -r ".[$i][4]")

    # Check if already in target state
    if [[ "$ACTION" == "stop" && "$CURRENT_STATE" == "stopped" ]] || [[ "$ACTION" == "start" && "$CURRENT_STATE" == "running" ]]; then
        log_info "  $INSTANCE_ID ($INSTANCE_NAME) already $CURRENT_STATE, skipping"
        SKIPPED+=("$INSTANCE_ID|$INSTANCE_NAME|$CURRENT_STATE|$INSTANCE_TYPE|$AZ")
        continue
    fi

    # Skip instances in transitional states
    if [[ "$CURRENT_STATE" =~ ^(stopping|pending|shutting-down|terminating)$ ]]; then
        log_warn "  $INSTANCE_ID ($INSTANCE_NAME) in transitional state $CURRENT_STATE, skipping"
        SKIPPED+=("$INSTANCE_ID|$INSTANCE_NAME|$CURRENT_STATE|$INSTANCE_TYPE|$AZ")
        continue
    fi

    TARGET_INSTANCES+=("$INSTANCE_ID|$INSTANCE_NAME|$CURRENT_STATE|$INSTANCE_TYPE|$AZ")
done

if [[ ${#TARGET_INSTANCES[@]} -eq 0 ]]; then
    log_ok "No instances need $ACTION. All already in target state or transitional."
    exit 0
fi

# ─── Dry-run preview ───────────────────────────────────────────────────────
log_info "Instances to $ACTION:"
echo "--------------------------------------------------"
printf "%-20s %-25s %-12s %-12s %sn" "INSTANCE ID" "NAME" "CURRENT" "TYPE" "AZ"
echo "--------------------------------------------------"
for entry in "${TARGET_INSTANCES[@]}"; do
    IFS='|' read -r id name state type az <<< "$entry"
    printf "%-20s %-25s %-12s %-12s %sn" "$id" "$name" "$state" "$type" "$az"
done
echo "--------------------------------------------------"

if [[ "$DRY_RUN" == "true" ]]; then
    log_ok "Dry-run complete. ${#TARGET_INSTANCES[@]} instance(s) would be $ACTION'd."
    exit 0
fi

# ─── Execute action ────────────────────────────────────────────────────────
INSTANCE_IDS=()
for entry in "${TARGET_INSTANCES[@]}"; do
    IFS='|' read -r id name state type az <<< "$entry"
    INSTANCE_IDS+=("$id")
done

log_info "Executing $ACTION on ${#INSTANCE_IDS[@]} instance(s)..."

if [[ "$ACTION" == "stop" ]]; then
    aws ec2 stop-instances --region "$REGION" --instance-ids "${INSTANCE_IDS[@]}" --output json > /tmp/scheduler_result.json
    WAITER="instance-stopped"
else
    aws ec2 start-instances --region "$REGION" --instance-ids "${INSTANCE_IDS[@]}" --output json > /tmp/scheduler_result.json
    WAITER="instance-running"
fi

# ─── Wait for completion ───────────────────────────────────────────────────
log_info "Waiting for instances to reach target state..."
FAILED=0

for id in "${INSTANCE_IDS[@]}"; do
    if aws ec2 wait "$WAITER" --region "$REGION" --instance-ids "$id"; then
        log_ok "  $id reached target state"
    else
        log_error "  $id failed to reach target state (timeout or error)"
        ((FAILED++))
    fi
done

# ─── Final report ──────────────────────────────────────────────────────────
log_info "Final state verification:"
echo "--------------------------------------------------"
printf "%-20s %-25s %-12s %sn" "INSTANCE ID" "NAME" "FINAL STATE" "NOTES"
echo "--------------------------------------------------"

for entry in "${TARGET_INSTANCES[@]}"; do
    IFS='|' read -r id name old_state type az <<< "$entry"
    FINAL_STATE=$(aws ec2 describe-instances --region "$REGION" --instance-ids "$id" --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "unknown")
    if [[ "$ACTION" == "stop" && "$FINAL_STATE" == "stopped" ]] || [[ "$ACTION" == "start" && "$FINAL_STATE" == "running" ]]; then
        printf "%-20s %-25s %-12s %sn" "$id" "$name" "$FINAL_STATE" "OK"
    else
        printf "%-20s %-25s %-12s %sn" "$id" "$name" "$FINAL_STATE" "MISMATCH"
    fi
done

for entry in "${SKIPPED[@]}"; do
    IFS='|' read -r id name state type az <<< "$entry"
    printf "%-20s %-25s %-12s %sn" "$id" "$name" "$state" "SKIPPED"
done

echo "--------------------------------------------------"

SUCCESS_COUNT=$((${#TARGET_INSTANCES[@]} - FAILED))
log_ok "Action $ACTION complete: $SUCCESS_COUNT succeeded, $FAILED failed, ${#SKIPPED[@]} skipped"

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

Script Breakdown

Lines 1-15: Shebang and strict mode

set -euo pipefail ensures any failure in AWS CLI calls or pipelines stops the script immediately.

Lines 17-25: Configuration defaults

Tag key defaults to Environment with values dev,test,staging. This is a common organizational convention. The EXCLUDE_TAGS array allows fine-grained opt-outs (e.g., Owner=critical-team).

Lines 57-80: Argument parsing

Requires --action start|stop. Supports comma-separated tag values and repeatable --exclude-tags for exceptions.

Lines 82-100: Tag filter construction

Builds --filters array for describe-instances. Includes both include-filters (matching desired environments) and exclude-filters (opt-out tags).

Lines 102-125: Instance discovery with state filtering

Queries instances in running,stopped,stopping,pending states. Extracts ID, Name tag, current state, instance type, AZ, and all tags via JMESPath.

Lines 127-150: State transition logic

Skips instances already in target state (idempotent). Skips transitional states (stopping,pending,shutting-down,terminating) to avoid conflicts. This is critical — acting on a stopping instance causes API errors.

Lines 152-170: Dry-run preview table

Shows exactly which instances will be affected before any API call. Includes instance type and AZ for capacity planning.

Lines 172-195: Execute and wait

Calls stop-instances or start-instances with all IDs in one batch (single API call). Then waits for each instance using wait instance-stopped or wait instance-running. Per-instance wait ensures partial failures are caught.

Lines 197-225: Final verification report

Re-queries each instance to confirm final state. Reports mismatches (e.g., instance didn’t stop in time). Exit code reflects success/failure.

Dry Run / Testing

# Preview stop action
./ec2-scheduler.sh --action stop --dry-run

# Preview start action with custom tags
./ec2-scheduler.sh --action start --dry-run --tag-key Environment --tag-values dev,test

# Exclude specific instances
./ec2-scheduler.sh --action stop --dry-run --exclude-tags Owner=platform-team

Verify the preview table shows correct instances. Check that no state changes occur in the AWS console.

Run the Script

chmod +x ec2-scheduler.sh

# Stop dev/test instances
./ec2-scheduler.sh --action stop --region us-east-1

# Start dev/test instances
./ec2-scheduler.sh --action start --region us-east-1

Expected output (stop action):

[INFO] Starting EC2 scheduler
[INFO] Action: stop | Region: us-east-1 | Tag filter: Environment in [dev test staging] | Dry-run: false
[INFO] Discovering instances...
[OK] Found 5 instance(s)
[INFO] Instances to stop:
--------------------------------------------------
INSTANCE ID          NAME                        CURRENT      TYPE         AZ
--------------------------------------------------
i-0abc123            web-dev-1                   running      t3.medium    us-east-1a
i-0def456            api-dev-1                   running      t3.small     us-east-1b
i-0ghi789            db-staging-1                running      r5.large     us-east-1a
--------------------------------------------------
[INFO] Executing stop on 3 instance(s)...
[INFO] Waiting for instances to reach target state...
[OK]   i-0abc123 reached target state
[OK]   i-0def456 reached target state
[OK]   i-0ghi789 reached target state
[INFO] Final state verification:
--------------------------------------------------
INSTANCE ID          NAME                        FINAL STATE  NOTES
--------------------------------------------------
i-0abc123            web-dev-1                   stopped      OK
i-0def456            api-dev-1                   stopped      OK
i-0ghi789            db-staging-1                stopped      OK
--------------------------------------------------
[OK] Action stop complete: 3 succeeded, 0 failed, 2 skipped

Automate the Automation

Option 1: cron (two entries — stop nights, start mornings)

# Stop at 19:00 UTC (end of business day US)
0 19 * * 1-5 /opt/scripts/ec2-scheduler.sh --action stop --region us-east-1 >> /var/log/ec2-scheduler.log 2>&1

# Start at 11:00 UTC (start of business day US)
0 11 * * 1-5 /opt/scripts/ec2-scheduler.sh --action start --region us-east-1 >> /var/log/ec2-scheduler.log 2>&1

Runs weekdays only. Adjust hours for your timezone.

Option 2: systemd timer pair

# /etc/systemd/system/ec2-scheduler-stop.service
[Unit]
Description=EC2 Stop Scheduler
[Service]
Type=oneshot
ExecStart=/opt/scripts/ec2-scheduler.sh --action stop --region us-east-1
User=scheduler

# /etc/systemd/system/ec2-scheduler-stop.timer
[Unit]
Description=Stop EC2 instances weekdays 19:00 UTC
[Timer]
OnCalendar=Mon..Fri 19:00
Persistent=true
[Install]
WantedBy=timers.target

# /etc/systemd/system/ec2-scheduler-start.service
[Unit]
Description=EC2 Start Scheduler
[Service]
Type=oneshot
ExecStart=/opt/scripts/ec2-scheduler.sh --action start --region us-east-1
User=scheduler

# /etc/systemd/system/ec2-scheduler-start.timer
[Unit]
Description=Start EC2 instances weekdays 11:00 UTC
[Timer]
OnCalendar=Mon..Fri 11:00
Persistent=true
[Install]
WantedBy=timers.target

Option 3: AWS EventBridge Scheduler + Lambda (recommended for production)

Create two EventBridge Schedule rules (cron expressions) targeting a Lambda function that runs the start/stop logic. Benefits:

  • No host to manage
  • Built-in retry and dead-letter queue
  • CloudWatch metrics/logs automatically
  • Can target multiple regions/accounts via cross-account roles

Lambda pseudocode:

import boto3, os

def lambda_handler(event, context):
    action = event['action']  # 'start' or 'stop'
    region = os.environ['REGION']
    tag_key = os.environ.get('TAG_KEY', 'Environment')
    tag_values = os.environ.get('TAG_VALUES', 'dev,test,staging').split(',')
    
    ec2 = boto3.client('ec2', region_name=region)
    # ... filter instances by tag, call start_instances/stop_instances, wait
    return {'statusCode': 200, 'body': f'{action} complete'}

Option 4: AWS Instance Scheduler (managed solution)

AWS provides the Instance Scheduler solution — a CloudFormation stack that deploys DynamoDB, Lambda, and EventBridge for configurable schedules with timezone support, holiday calendars, and multi-account. Use this instead of custom scripts for most cases.

Scheduling Example

# Stop: weekdays 7 PM UTC (adjust for your timezone)
0 19 * * 1-5 /opt/scripts/ec2-scheduler.sh --action stop --region us-east-1

# Start: weekdays 11 AM UTC
0 11 * * 1-5 /opt/scripts/ec2-scheduler.sh --action start --region us-east-1

# Or: stop weekends entirely
0 23 * * 5 /opt/scripts/ec2-scheduler.sh --action stop --region us-east-1
0 11 * * 1 /opt/scripts/ec2-scheduler.sh --action start --region us-east-1

Always document the UTC ⇄ local time mapping. Example: 19:00 UTC = 3 PM EDT / 12 PM PDT.

Cost Awareness

Stopping instances saves compute costs but not all costs:

Cost ComponentStopped?Notes
Instance hours (On-Demand/Spot)YesPrimary savings. ~$0.04-2.00/hr per instance
EBS volumes attachedNoStill charged at $0.08-0.10/GB-month
Elastic IPs (unassociated)No$0.005/hr when not attached to running instance
Data transferYesNo traffic when stopped

For a t3.medium (2 vCPU, 4 GB) running 12 hrs/day vs 24 hrs/day in us-east-1 On-Demand:

  • 24/7: ~$30.66/month
  • 12 hrs/day (weekdays only): ~$10.95/month
  • Savings: ~64%

Multiply by instance count. A team with 20 dev instances saves ~$400/month.

Logging

  • stdout/stderr → /var/log/ec2-scheduler.log (cron) or journald (systemd)
  • Exit codes: 0 = all ok, 1 = any failure
  • CloudWatch: Automatic with Lambda/EventBridge
  • Structured output: Script outputs tables parseable by log agents

Wrapper for SNS alerting on failure:

#!/usr/bin/env bash
/opt/scripts/ec2-scheduler.sh "$@" 2>&1 | tee -a /var/log/ec2-scheduler.log
EXIT_CODE=${PIPESTATUS[0]}
if [[ $EXIT_CODE -ne 0 ]]; then
    aws sns publish --topic-arn arn:aws:sns:us-east-1:123456789012:scheduler-alerts 
        --subject "EC2 Scheduler FAILED" 
        --message "$(tail -30 /var/log/ec2-scheduler.log)"
fi
exit $EXIT_CODE

Error Handling

Failure ScenarioHow the Script Handles It
Authentication failureAWS CLI exits non-zero; set -e stops script
Permission denied (IAM)Clear AWS error: “You are not authorized to perform this operation”
No matching instancesLogs warning, exits 0 (not an error)
Instance in transitional stateDetected and skipped with warning; avoids API conflict
Instance already in target stateSkipped (idempotent); logged as “already stopped/running”
Wait timeout (instance stuck)Per-instance wait fails; logged; other instances continue; exit code 1
API throttlingAWS CLI retries automatically; add AWS_MAX_ATTEMPTS=10 for large fleets
Mixed success/failureReports each instance final state; non-zero exit if any failed

Production Considerations

  • IAM condition keys: The policy uses ec2:ResourceTag/Environment to enforce tag-based authorization at the API level — defense in depth.
  • Timezones: Schedule in UTC. Document local equivalent. Consider AWS Instance Scheduler for timezone-aware schedules.
  • Holidays/outages: Custom scripts don’t handle holidays. Instance Scheduler supports holiday calendars.
  • ASG/Launch Template instances: Stopping instances in Auto Scaling Groups triggers replacement. Tag ASG instances differently or exclude them.
  • Stateful workloads: Databases, queues, caches need graceful shutdown. Add pre-stop hooks (SSM Run Command) or exclude from scheduling.
  • Spot instances: Can be interrupted. Script works but consider capacity rebalancing.
  • Cross-region: Run script per region or use Instance Scheduler multi-region.
  • Monitoring: Alert on non-zero exit. Track instance-hours saved via Cost Explorer.
  • Audit trail: CloudTrail logs all StartInstances/StopInstances calls with user/role identity.

Common Mistakes

  1. No tag strategy — instances untagged = not managed. Enforce tags via IaC (Terraform required_tags).
  2. Hard-coded instance IDs — breaks on replacement. Use tags.
  3. Forgetting exclude tags — critical dev instances get stopped. Use --exclude-tags or IAM conditions.
  4. Running against production — IAM condition prevents this, but test in separate account first.
  5. Wrong timezone in cron — cron uses system TZ; verify with date.
  6. No dry-run before first real run — always preview.
  7. Stopping ASG instances — ASG launches replacements, doubling cost. Tag differently.
  8. No alerting on failure — silent failures = instances left running/stopped.
  9. Ignoring EBS costs — stopped instances still pay for storage.
  10. Not testing start after stop — verify instances come up healthy (health checks, application readiness).

How to Improve the Script

Beginner (current)

Tag-based start/stop with dry-run, wait, verification, and exclude tags.

Intermediate

  • Config file (YAML) for multiple schedules/tag sets per region
  • Pre-stop hook: run SSM command to gracefully stop apps (systemctl stop, flush DB)
  • Post-start health check: poll HTTP endpoint or SSM command to verify readiness
  • Slack/Teams notification with instance list and estimated savings
  • Integration with AWS Cost Explorer API for actual savings reporting

Production-oriented

  • Deploy as AWS Instance Scheduler (managed) or custom Lambda + EventBridge
  • Multi-account via StackSets / Control Tower
  • Timezone-aware schedules with holiday calendar (DynamoDB)
  • Integration with AWS Compute Optimizer for right-sizing recommendations
  • Automated tag compliance: flag untagged instances via Config rule

Alternatives

AWS Instance Scheduler — Official AWS solution (CloudFormation). Features: timezone support, holiday calendars, multi-account, SSM maintenance windows, hibernation support, CloudWatch metrics. Deploy once, configure via DynamoDB/SSM Parameter Store.

When to use Instance Scheduler instead: Almost always. It’s maintained by AWS, handles edge cases (holidays, timezones, ASG integration), and requires no script maintenance.

When a custom script makes sense:

  • Simple single-account, single-timezone, no-holiday needs
  • Pre/post action hooks not supported by Instance Scheduler
  • Integration with custom CMDB or ticketing
  • Learning/educational purposes

EC2 Hibernation — Alternative to stop/start for faster resume. Preserves RAM to EBS. Requires supported instance types and encrypted root volume. Higher storage cost while hibernated.

DevStackIO Tools

Related DevStackIO Articles

Conclusion

Problem: Dev/test EC2 instances running 24/7 waste 50-65% of compute spend.

Automation: A tag-driven Bash script that stops instances on schedule, starts them on another, verifies state transitions, and protects production via IAM conditions and exclude tags — with dry-run preview.

What you learned: How to use describe-instances with compound tag filters, stop-instances/start-instances with batch IDs, wait instance-stopped/running for reliability, and IAM condition keys for guardrails.

Most important safety consideration: Use IAM condition keys (ec2:ResourceTag/Environment) to prevent production impact at the API level — not just script logic. Always dry-run first.

Next logical improvement: Replace with AWS Instance Scheduler for managed timezone/holiday support, multi-account, and zero infrastructure — or Lambda/EventBridge for serverless with custom hooks.

Sources

Technical Verification Notes

  • All AWS CLI commands verified against AWS CLI v2 Command Reference (v2.27.54)
  • stop-instances/start-instances accept multiple --instance-ids in one call
  • wait instance-stopped polls until state stopped; wait instance-running polls until running
  • IAM condition ec2:ResourceTag/Environment enforces tag-based authorization at API level
  • Script handles transitional states (stopping,pending) to avoid InvalidInstanceState errors

Potential Future Improvements

  • Add hibernation support (--hibernate flag on stop)
  • ASG-aware logic: suspend/resume ASG processes instead of stopping instances
  • Cost Explorer integration: actual savings per run
  • Multi-region parallel execution
  • Slack/Teams interactive approval for production-adjacent instances

Related Tools

Try these companion tools on DevStackIO Tools: