Scenario Intermediate Python Python AWS Scripting

Auto Stop/Start EC2 Instances Using Schedule Tags with Python

Write a Python boto3 script that reads the AutoStop=true tag on EC2 instances and automatically stops them at 8 PM and starts them at 8 AM — with detailed explanation of every command.

January 20, 2025 15 min read ~20 min to complete DB
The Situation

Cost-saving automation for non-production EC2 instances (dev/test/staging) that don't need to run 24/7.

8 Steps
5 Services Used
~20 min Duration
Intermediate Difficulty

Problem Statement

Your team has 20 dev/staging EC2 instances that run 24/7 but are only used during business hours (8 AM – 8 PM). Each instance costs ~$0.10/hour. Running them overnight wastes $0.10 × 12 hours × 20 instances = $24/day — nearly $730/month in idle compute.

Goal: Write a Python script that:

  • Finds all instances tagged AutoStop=true
  • Stops them at 8 PM every day
  • Starts them at 8 AM every day
  • Logs all actions so you can audit what happened

Prerequisites Setup

Step 1 — Install dependencies

pip install boto3 schedule
PackagePurpose
boto3AWS SDK for Python — talks to EC2, S3, Lambda, etc.
scheduleLightweight job scheduler — runs functions at set times

Step 2 — Configure AWS credentials

# Option A — AWS CLI (recommended for local use)
aws configure
# AWS Access Key ID:     AKIA...
# AWS Secret Access Key: xxxxxxxx
# Default region:        ap-south-1
# Default output format: json

# Option B — Environment variables (for CI/CD or Docker)
export AWS_ACCESS_KEY_ID=AKIA...
export AWS_SECRET_ACCESS_KEY=xxxxxxxx
export AWS_DEFAULT_REGION=ap-south-1

# Option C — IAM Role (best for EC2/Lambda — no credentials needed)
# Attach an IAM role with the right permissions to the instance running this script

Step 3 — Tag your EC2 instances

# Tag an instance via AWS CLI
aws ec2 create-tags \
  --resources i-0abc123def456789 \
  --tags Key=AutoStop,Value=true

# Or tag multiple instances at once
aws ec2 create-tags \
  --resources i-0abc123def456789 i-0def456789abc123 \
  --tags Key=AutoStop,Value=true

# Verify the tag
aws ec2 describe-tags \
  --filters "Name=resource-id,Values=i-0abc123def456789"

Step 4 — Required IAM permissions

Create an IAM policy and attach it to the user or role running this script:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EC2ScheduleControl",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:DescribeTags",
        "ec2:StartInstances",
        "ec2:StopInstances"
      ],
      "Resource": "*"
    }
  ]
}

The Complete Script

# ec2_scheduler.py
"""
EC2 Auto Stop/Start Scheduler
Stops instances tagged AutoStop=true at 8 PM.
Starts them at 8 AM.
"""

import boto3
import schedule
import time
import logging
from datetime import datetime
from botocore.exceptions import ClientError, NoCredentialsError

# ── Logging setup ────────────────────────────────────────────────
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s  %(levelname)-8s  %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    handlers=[
        logging.StreamHandler(),                    # Print to console
        logging.FileHandler("ec2_scheduler.log"),   # Also write to file
    ],
)
log = logging.getLogger(__name__)

# ── Configuration ─────────────────────────────────────────────────
REGION        = "ap-south-1"   # Change to your AWS region
TAG_KEY       = "AutoStop"     # The tag key we look for
TAG_VALUE     = "true"         # The tag value we look for
STOP_TIME     = "20:00"        # 8 PM — 24-hour format
START_TIME    = "08:00"        # 8 AM — 24-hour format


# ── boto3 client ──────────────────────────────────────────────────
def get_ec2_client():
    """
    Create an EC2 client for the specified region.

    boto3.client() creates a low-level service client.
    - 'ec2'     : the AWS service name
    - region_name: which AWS region to connect to
    Credentials are picked up automatically from:
      1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
      2. ~/.aws/credentials file (set by `aws configure`)
      3. IAM role attached to the EC2/Lambda running this script
    """
    return boto3.client("ec2", region_name=REGION)


# ── Helper: find tagged instances ─────────────────────────────────
def get_tagged_instances(ec2, desired_state: str) -> list[dict]:
    """
    Return EC2 instances that have AutoStop=true AND are in the desired_state.

    Parameters
    ----------
    ec2           : boto3 EC2 client
    desired_state : 'running' to find instances to stop,
                    'stopped' to find instances to start

    How describe_instances works
    ----------------------------
    - Filters is a list of dicts with 'Name' and 'Values' keys.
    - 'tag:AutoStop'     matches the tag KEY named AutoStop.
    - 'instance-state-name' filters by the current lifecycle state.
    - The API returns a paginated response — Reservations is the top-level list.
      Each Reservation can contain multiple Instances (from a single launch command).
    """
    try:
        response = ec2.describe_instances(
            Filters=[
                {
                    "Name":   f"tag:{TAG_KEY}",    # Filter by tag key
                    "Values": [TAG_VALUE],          # Tag value must be 'true'
                },
                {
                    "Name":   "instance-state-name",
                    "Values": [desired_state],      # Only running or stopped
                },
            ]
        )
    except NoCredentialsError:
        log.error("AWS credentials not found. Run 'aws configure' or set env vars.")
        return []
    except ClientError as e:
        log.error(f"AWS API error: {e.response['Error']['Message']}")
        return []

    # Flatten the nested Reservations → Instances structure into a flat list
    instances = [
        instance
        for reservation in response["Reservations"]
        for instance in reservation["Instances"]
    ]

    return instances


# ── Helper: get a human-readable instance name ─────────────────────
def get_instance_name(instance: dict) -> str:
    """
    Extract the 'Name' tag value from the instance's Tags list.

    Tags is a list of {'Key': '...', 'Value': '...'} dicts.
    We use next() with a default so it doesn't crash if 'Name' tag is absent.
    """
    tags = instance.get("Tags", [])
    name = next((t["Value"] for t in tags if t["Key"] == "Name"), "Unnamed")
    return name


# ── STOP action ───────────────────────────────────────────────────
def stop_instances():
    """
    Find all running instances tagged AutoStop=true and stop them.
    Called automatically at STOP_TIME (8 PM).
    """
    log.info("=" * 55)
    log.info(f"[STOP JOB] Starting at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")

    ec2 = get_ec2_client()
    instances = get_tagged_instances(ec2, desired_state="running")

    if not instances:
        log.info("[STOP JOB] No running instances with AutoStop=true found.")
        return

    # Extract just the instance IDs — that's what start/stop APIs need
    instance_ids = [inst["InstanceId"] for inst in instances]

    log.info(f"[STOP JOB] Found {len(instance_ids)} instance(s) to stop:")
    for inst in instances:
        log.info(f"  - {inst['InstanceId']}  ({get_instance_name(inst)})")

    try:
        """
        ec2.stop_instances() sends a stop signal to each instance.
        - InstanceIds: list of instance ID strings (e.g. ['i-0abc...', 'i-0def...'])
        - The API call is asynchronous — it returns immediately.
          The instance transitions: running → stopping → stopped.
        - DryRun=True can be used to test without actually stopping.
        - StopInstances does NOT terminate (delete) the instance.
          Data on the EBS root volume is preserved.
        """
        response = ec2.stop_instances(InstanceIds=instance_ids)

        # Log the new state reported by AWS for each instance
        for item in response["StoppingInstances"]:
            prev  = item["PreviousState"]["Name"]
            curr  = item["CurrentState"]["Name"]
            iid   = item["InstanceId"]
            log.info(f"  ✓ {iid}: {prev}{curr}")

        log.info(f"[STOP JOB] Stop signal sent to {len(instance_ids)} instance(s).")

    except ClientError as e:
        error_code = e.response["Error"]["Code"]
        error_msg  = e.response["Error"]["Message"]
        log.error(f"[STOP JOB] Failed to stop instances. Code: {error_code}{error_msg}")
        # Common errors:
        # UnsupportedOperation: instance store-backed instances can't be stopped
        # IncorrectInstanceState: instance is already stopping/terminated


# ── START action ──────────────────────────────────────────────────
def start_instances():
    """
    Find all stopped instances tagged AutoStop=true and start them.
    Called automatically at START_TIME (8 AM).
    """
    log.info("=" * 55)
    log.info(f"[START JOB] Starting at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")

    ec2 = get_ec2_client()
    instances = get_tagged_instances(ec2, desired_state="stopped")

    if not instances:
        log.info("[START JOB] No stopped instances with AutoStop=true found.")
        return

    instance_ids = [inst["InstanceId"] for inst in instances]

    log.info(f"[START JOB] Found {len(instance_ids)} instance(s) to start:")
    for inst in instances:
        log.info(f"  - {inst['InstanceId']}  ({get_instance_name(inst)})")

    try:
        """
        ec2.start_instances() boots stopped EBS-backed instances.
        - InstanceIds: list of instance ID strings.
        - The API call is asynchronous.
          The instance transitions: stopped → pending → running.
        - A new public IP is assigned (unless an Elastic IP is attached).
        - Instance store data is NOT preserved across stop/start cycles.
          EBS data IS preserved.
        """
        response = ec2.start_instances(InstanceIds=instance_ids)

        for item in response["StartingInstances"]:
            prev  = item["PreviousState"]["Name"]
            curr  = item["CurrentState"]["Name"]
            iid   = item["InstanceId"]
            log.info(f"  ✓ {iid}: {prev}{curr}")

        log.info(f"[START JOB] Start signal sent to {len(instance_ids)} instance(s).")

    except ClientError as e:
        error_code = e.response["Error"]["Code"]
        error_msg  = e.response["Error"]["Message"]
        log.error(f"[START JOB] Failed to start instances. Code: {error_code}{error_msg}")


# ── Scheduler setup ───────────────────────────────────────────────
def setup_schedule():
    """
    Register stop and start jobs with the schedule library.

    schedule.every().day.at("HH:MM") registers a recurring daily job.
    - Times use 24-hour format.
    - The scheduler runs in the LOCAL timezone of the machine.
      If you need UTC or a specific timezone, use pytz:
          import pytz
          tz = pytz.timezone("Asia/Kolkata")
          now = datetime.now(tz)
    """
    schedule.every().day.at(STOP_TIME).do(stop_instances)
    schedule.every().day.at(START_TIME).do(start_instances)

    log.info(f"Scheduler active — Stop: {STOP_TIME} | Start: {START_TIME}")
    log.info(f"Region: {REGION} | Tag filter: {TAG_KEY}={TAG_VALUE}")
    log.info("Waiting for next scheduled job... (Ctrl+C to exit)")


# ── Entry point ───────────────────────────────────────────────────
if __name__ == "__main__":
    setup_schedule()

    """
    schedule.run_pending() checks if any registered job is due and runs it.
    It does NOT block — it returns immediately if no job is due.

    We wrap it in an infinite loop with time.sleep(60) so we check
    every 60 seconds. This is lightweight (almost no CPU usage while sleeping).

    The loop runs forever until:
    - You press Ctrl+C (raises KeyboardInterrupt)
    - The process is killed (systemd, Docker, etc.)
    """
    try:
        while True:
            schedule.run_pending()
            time.sleep(60)   # Check every minute — fine-grained enough for HH:MM scheduling
    except KeyboardInterrupt:
        log.info("Scheduler stopped by user.")

Running the Script

Option A — Run directly (for testing)

# Run and watch the logs
python ec2_scheduler.py

# 2025-01-20 07:59:00  INFO     Scheduler active — Stop: 20:00 | Start: 08:00
# 2025-01-20 07:59:00  INFO     Region: ap-south-1 | Tag filter: AutoStop=true
# 2025-01-20 08:00:00  INFO     ═══════════════════════════════════════════════════════
# 2025-01-20 08:00:00  INFO     [START JOB] Starting at 2025-01-20 08:00:00
# 2025-01-20 08:00:00  INFO     Found 3 instance(s) to start:
# 2025-01-20 08:00:00  INFO       - i-0abc123  (dev-api-server)
# 2025-01-20 08:00:00  INFO       - i-0def456  (staging-db)
# 2025-01-20 08:00:00  INFO       - i-0ghi789  (test-worker)
# 2025-01-20 08:00:01  INFO       ✓ i-0abc123: stopped → pending
# 2025-01-20 08:00:01  INFO       ✓ i-0def456: stopped → pending
# 2025-01-20 08:00:01  INFO       ✓ i-0ghi789: stopped → pending

Option B — Run as a Linux systemd service (production)

# /etc/systemd/system/ec2-scheduler.service
[Unit]
Description=EC2 Auto Stop/Start Scheduler
After=network.target

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/ec2-scheduler
ExecStart=/opt/ec2-scheduler/venv/bin/python ec2_scheduler.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable ec2-scheduler
sudo systemctl start ec2-scheduler
sudo systemctl status ec2-scheduler
journalctl -u ec2-scheduler -f   # Follow live logs

Option C — Run as a cron job (alternative)

# Edit crontab
crontab -e

# Add these two lines:
# Stop at 8 PM (20:00) every day
0 20 * * * /usr/bin/python3 /opt/ec2-scheduler/ec2_scheduler_once.py stop >> /var/log/ec2-stop.log 2>&1

# Start at 8 AM (08:00) every day
0 8  * * * /usr/bin/python3 /opt/ec2-scheduler/ec2_scheduler_once.py start >> /var/log/ec2-start.log 2>&1
# ec2_scheduler_once.py — for cron (runs once, stops/starts, then exits)
import sys
from ec2_scheduler import start_instances, stop_instances

if __name__ == "__main__":
    action = sys.argv[1] if len(sys.argv) > 1 else "stop"
    if action == "stop":
        stop_instances()
    elif action == "start":
        start_instances()

Option D — AWS Lambda + EventBridge (serverless, no server needed)

# lambda_handler.py — deploy this as a Lambda function
import boto3
from botocore.exceptions import ClientError

TAG_KEY   = "AutoStop"
TAG_VALUE = "true"
REGION    = "ap-south-1"

def lambda_handler(event, context):
    """
    EventBridge triggers this Lambda on a cron schedule.
    event['action'] is set by the EventBridge rule's Input field.
    """
    action = event.get("action", "stop")
    ec2 = boto3.client("ec2", region_name=REGION)

    state = "running" if action == "stop" else "stopped"
    response = ec2.describe_instances(
        Filters=[
            {"Name": f"tag:{TAG_KEY}", "Values": [TAG_VALUE]},
            {"Name": "instance-state-name", "Values": [state]},
        ]
    )

    instance_ids = [
        inst["InstanceId"]
        for r in response["Reservations"]
        for inst in r["Instances"]
    ]

    if not instance_ids:
        return {"status": "no instances found", "action": action}

    if action == "stop":
        ec2.stop_instances(InstanceIds=instance_ids)
    else:
        ec2.start_instances(InstanceIds=instance_ids)

    return {
        "status": "success",
        "action": action,
        "instances": instance_ids,
        "count": len(instance_ids),
    }
# EventBridge rules (AWS Console or CLI)
# Stop rule — every day at 8 PM UTC
aws events put-rule \
  --name ec2-stop-rule \
  --schedule-expression "cron(0 14 * * ? *)" \
  --state ENABLED

# Start rule — every day at 8 AM UTC
aws events put-rule \
  --name ec2-start-rule \
  --schedule-expression "cron(0 2 * * ? *)" \
  --state ENABLED

Key Commands Explained

CommandWhat it does
boto3.client("ec2", region_name=REGION)Creates an EC2 API client for the specified region
ec2.describe_instances(Filters=[...])Lists instances matching the tag + state filter
response["Reservations"]Top-level grouping returned by EC2 describe — each holds 1+ instances
ec2.stop_instances(InstanceIds=[...])Sends stop signal; instance goes running → stopping → stopped
ec2.start_instances(InstanceIds=[...])Sends start signal; instance goes stopped → pending → running
schedule.every().day.at("20:00").do(fn)Registers fn to run daily at 20:00 in local time
schedule.run_pending()Executes any jobs that are due — called in the event loop
item["PreviousState"]["Name"]Reports what the instance state was before the API call
item["CurrentState"]["Name"]Reports the transitional state immediately after the API call

Cost Savings Estimate

ScenarioInstancesHours saved/dayCost/hrDaily savingMonthly saving
Small team512$0.10$6~$180
Medium team2012$0.10$24~$720
Large team5012$0.10$60~$1,800

The script pays for itself (Lambda cost: ~$0/month on free tier) the first day it runs.


Common Issues & Fixes

NoCredentialsError — AWS credentials not configured. Run aws configure or set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY environment variables.

UnauthorizedOperation — The IAM user/role doesn’t have ec2:StopInstances or ec2:StartInstances permission. Attach the policy from Step 4.

UnsupportedOperation: You may not stop instance-store instances — Instance-store backed AMIs cannot be stopped, only terminated. Only EBS-backed instances support stop/start.

Instances not found — Double-check the tag spelling: AutoStop (capital A and S) with value true (lowercase). Tags are case-sensitive in AWS.

Wrong timezoneschedule uses the machine’s local time. If your server is in UTC and you want 8 PM IST (UTC+5:30), set STOP_TIME = "14:30" (20:00 − 5:30 = 14:30 UTC).


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used & How It Works
import boto3AWS SDK for Python. Every AWS API call goes through this library. Without it you cannot talk to EC2. Install: pip install boto3
import scheduleLightweight job scheduler. Lets you call a function at “20:00 every day”. Install: pip install schedule
import timePython standard library. We use time.sleep(60) to pause the event loop for 60 seconds between scheduler checks
import loggingStandard library for writing structured log output to both console and file
from datetime import datetimeUsed to format the current timestamp inside log messages (e.g., "2025-01-20 08:00:00")
from botocore.exceptions import ClientError, NoCredentialsErrorAWS SDK error classes. ClientError covers all AWS API errors (wrong permissions, wrong state, etc.). NoCredentialsError fires when no AWS credentials are found anywhere

Configuration Constants

LineWhat It Does & Why
REGION = "ap-south-1"Which AWS region to call. EC2 is regional — instances in ap-south-1 can only be managed through the ap-south-1 endpoint
TAG_KEY = "AutoStop"The EC2 tag key we search for. Stored as a constant so changing the tag policy means changing one line
TAG_VALUE = "true"The expected tag value. Only instances tagged AutoStop=true are touched — anything else is left alone
STOP_TIME = "20:00"8 PM in 24-hour format. The schedule library parses this string directly
START_TIME = "08:00"8 AM in 24-hour format

logging.basicConfig(...)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s  %(levelname)-8s  %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    handlers=[
        logging.StreamHandler(),
        logging.FileHandler("ec2_scheduler.log"),
    ],
)
LineExplanation
level=logging.INFOSets the minimum severity level to log. INFO logs everything except DEBUG. Set to logging.DEBUG when troubleshooting
format="%(asctime)s %(levelname)-8s %(message)s"Log line template. %(asctime)s = timestamp, %(levelname)-8s = level padded to 8 chars, %(message)s = the log text
datefmt="%Y-%m-%d %H:%M:%S"Timestamp format. Without this, the time includes milliseconds
logging.StreamHandler()Sends log lines to stdout (the terminal). Required to see logs when running interactively or in Docker
logging.FileHandler("ec2_scheduler.log")Also writes log lines to a file on disk. Both handlers run together — every log line goes to both

get_ec2_client()

def get_ec2_client():
    return boto3.client("ec2", region_name=REGION)
LineExplanation
boto3.client("ec2")Creates a low-level EC2 client. “ec2” is the AWS service name. The client exposes individual API methods like describe_instances, stop_instances, etc.
region_name=REGIONEvery boto3 EC2 client is region-scoped. Instances in ap-south-1 will not be visible to a client pointed at us-east-1
(credentials auto-discovered)boto3 finds credentials automatically in this order: environment variables → ~/.aws/credentials file → IAM role attached to the current EC2 instance or Lambda

get_tagged_instances(ec2, desired_state)

response = ec2.describe_instances(
    Filters=[
        {"Name": f"tag:{TAG_KEY}", "Values": [TAG_VALUE]},
        {"Name": "instance-state-name", "Values": [desired_state]},
    ]
)
LineExplanation
ec2.describe_instances(Filters=[...])Calls the EC2 DescribeInstances API. Without filters it returns ALL instances — the Filters list narrows results on the server side (faster, cheaper)
{"Name": f"tag:{TAG_KEY}", "Values": [TAG_VALUE]}tag:AutoStop is a special filter key. The tag: prefix tells EC2 to filter by tag key name. Values: ["true"] — only instances where AutoStop tag equals “true”
{"Name": "instance-state-name", "Values": [desired_state]}Filters by lifecycle state. "running" to find instances to stop; "stopped" to find instances to start. Other states: pending, stopping, terminated
response["Reservations"]EC2 groups instances into Reservations (a Reservation = one launch command). Each Reservation has an "Instances" list
for reservation in response["Reservations"]: for instance in reservation["Instances"]Double loop to flatten: Reservations → Instances → flat list

get_instance_name(instance)

tags = instance.get("Tags", [])
name = next((t["Value"] for t in tags if t["Key"] == "Name"), "Unnamed")
LineExplanation
instance.get("Tags", [])Gets the Tags list. .get(key, default) returns the default if the key is missing — instances with no tags would otherwise raise KeyError
next((...), "Unnamed")next() returns the first item from the generator expression. The second argument is the default if the generator yields nothing (i.e., no “Name” tag found)
t["Key"] == "Name"Tags are stored as a list of {"Key": "...", "Value": "..."} dicts — not as a dict. We must search linearly

stop_instances() — Core Stop Logic

response = ec2.stop_instances(InstanceIds=instance_ids)
for item in response["StoppingInstances"]:
    prev = item["PreviousState"]["Name"]
    curr = item["CurrentState"]["Name"]
LineExplanation
ec2.stop_instances(InstanceIds=instance_ids)Sends a graceful stop signal to all instances in the list. The OS is signalled to shut down cleanly (like pressing the power button). Data on EBS volumes is preserved
InstanceIds=instance_idsMust be a list of instance ID strings: ["i-0abc123", "i-0def456"]. Stops all of them in one API call — more efficient than a loop
response["StoppingInstances"]Each element is a dict with InstanceId, PreviousState, and CurrentState
item["PreviousState"]["Name"]State before the API call. Will be "running"
item["CurrentState"]["Name"]State immediately after. Will be "stopping" — the instance is not yet stopped at this point (stop is async)
except ClientError as e:Catches AWS API errors. e.response["Error"]["Code"] contains the error type string

start_instances() — Core Start Logic

response = ec2.start_instances(InstanceIds=instance_ids)
for item in response["StartingInstances"]:
    prev = item["PreviousState"]["Name"]   # "stopped"
    curr = item["CurrentState"]["Name"]    # "pending"
LineExplanation
ec2.start_instances(InstanceIds=instance_ids)Sends a boot signal to all stopped instances. The instance powers on, the OS boots, and services start
response["StartingInstances"]List of status objects — same structure as StoppingInstances
"pending" (CurrentState)The instance is transitioning to running. It takes ~30–90 seconds to become fully reachable
Instance gets a new public IPUnless an Elastic IP is attached, the public IP changes on every stop/start cycle

Scheduler Setup

schedule.every().day.at(STOP_TIME).do(stop_instances)
schedule.every().day.at(START_TIME).do(start_instances)
LineExplanation
schedule.every()Returns a Job object. The chain of calls builds the schedule rule
.daySpecifies the interval unit: “run every 1 day”
.at("20:00")Sets the exact time within the day. Uses the local machine timezone — important if your server is in UTC but you want a different timezone
.do(stop_instances)Registers stop_instances (without calling it) as the function to execute when the time arrives. Note: no parentheses — we pass the function object, not its return value

Event Loop

while True:
    schedule.run_pending()
    time.sleep(60)
LineExplanation
while True:Infinite loop — keeps the script running forever until you press Ctrl+C or kill the process
schedule.run_pending()Checks if any registered job is due RIGHT NOW and runs it. Returns immediately if nothing is due. Does NOT block
time.sleep(60)Pauses the loop for 60 seconds. Since our schedule uses HH:MM precision, checking every 60 seconds is more than fine. This keeps CPU usage near zero
except KeyboardInterrupt:Catches Ctrl+C gracefully — prints a shutdown message instead of an ugly traceback
Services Used
EC2boto3IAMCloudWatch EventsPython schedule
Prerequisites
  • Python 3.8+
  • boto3 installed
  • AWS credentials configured
  • IAM permissions: ec2:DescribeInstances, ec2:StartInstances, ec2:StopInstances
What You Learned
  • boto3 EC2 client
  • Tag-based filtering with Filters
  • Instance lifecycle (stop/start)
  • Scheduling with Python schedule library
  • Error handling for AWS API calls

Have a similar scenario to share?

Production incidents are the best teachers. Submit your real-world scenario and help others learn.

Open Google Form

Related Scenarios