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 --versionshowsaws-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:
- IAM role (EC2 instance profile) — Best for scripts running on EC2
- AWS CLI named profiles —
aws configure --profile ec2-scheduler - Environment variables —
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKEN - SSO —
aws configure ssofor 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 Component | Stopped? | Notes |
|---|---|---|
| Instance hours (On-Demand/Spot) | Yes | Primary savings. ~$0.04-2.00/hr per instance |
| EBS volumes attached | No | Still charged at $0.08-0.10/GB-month |
| Elastic IPs (unassociated) | No | $0.005/hr when not attached to running instance |
| Data transfer | Yes | No 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 Scenario | How the Script Handles It |
|---|---|
| Authentication failure | AWS CLI exits non-zero; set -e stops script |
| Permission denied (IAM) | Clear AWS error: “You are not authorized to perform this operation” |
| No matching instances | Logs warning, exits 0 (not an error) |
| Instance in transitional state | Detected and skipped with warning; avoids API conflict |
| Instance already in target state | Skipped (idempotent); logged as “already stopped/running” |
| Wait timeout (instance stuck) | Per-instance wait fails; logged; other instances continue; exit code 1 |
| API throttling | AWS CLI retries automatically; add AWS_MAX_ATTEMPTS=10 for large fleets |
| Mixed success/failure | Reports each instance final state; non-zero exit if any failed |
Production Considerations
- IAM condition keys: The policy uses
ec2:ResourceTag/Environmentto 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/StopInstancescalls with user/role identity.
Common Mistakes
- No tag strategy — instances untagged = not managed. Enforce tags via IaC (Terraform
required_tags). - Hard-coded instance IDs — breaks on replacement. Use tags.
- Forgetting exclude tags — critical dev instances get stopped. Use
--exclude-tagsor IAM conditions. - Running against production — IAM condition prevents this, but test in separate account first.
- Wrong timezone in cron — cron uses system TZ; verify with
date. - No dry-run before first real run — always preview.
- Stopping ASG instances — ASG launches replacements, doubling cost. Tag differently.
- No alerting on failure — silent failures = instances left running/stopped.
- Ignoring EBS costs — stopped instances still pay for storage.
- 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
- Cron Expression Generator — Build and verify stop/start schedules
- Timestamp Utilities — UTC ↔ local time conversion for scheduling
- JSON Formatter — Validate IAM policies and AWS CLI output
Related DevStackIO Articles
- Article 1: Automate AWS EC2 Backups with Bash and AWS CLI — Backup companion
- Article 3: Find and Delete Unused AWS EBS Volumes Automatically — Storage cleanup
- Article 4: Automatically Find Large AWS CloudWatch Log Groups — Log cost control
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
- AWS CLI: stop-instances
- AWS CLI: start-instances
- AWS CLI: wait instance-stopped
- AWS CLI: wait instance-running
- Amazon EC2 User Guide: Stop and Start Your Instance
- AWS Instance Scheduler Solution
- IAM Condition Keys for EC2
Technical Verification Notes
- All AWS CLI commands verified against AWS CLI v2 Command Reference (v2.27.54)
stop-instances/start-instancesaccept multiple--instance-idsin one callwait instance-stoppedpolls until statestopped;wait instance-runningpolls untilrunning- IAM condition
ec2:ResourceTag/Environmentenforces tag-based authorization at API level - Script handles transitional states (
stopping,pending) to avoidInvalidInstanceStateerrors
Potential Future Improvements
- Add hibernation support (
--hibernateflag 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:
- business days calculator — schedule EC2 instance start/stop on business days only.