Script 1: Write a Python script to start/stop EC2 instances based on a schedule tag
π EC2 Auto Scheduler β Stop & Start Instances on a Schedule Using Python & AWS Lambda
Automatically stop your EC2 instances at 8 PM and start them at 8 AM using a tag-based Lambda function and EventBridge schedules β saving costs without lifting a finger.
π Table of Contents
- Overview
- Architecture Diagram
- Prerequisites
- How It Works
- Step 1 β Tag Your EC2 Instances
- Step 2 β The Lambda Function (Deep Dive)
- Step 3 β Create the EventBridge Schedule Rules
- Step 4 β IAM Permissions Required
- Step 5 β Deploy the Lambda Function
- Step 6 β Verify It Works
- Complete Code
- Common Errors & Fixes
- Cost Savings Estimate
- Extending the Solution
Overview
Running EC2 instances 24/7 in non-production environments (dev, staging, QA) is one of the most common sources of unnecessary AWS spend. If developers only work 8 AMβ8 PM, your instances are idle and billing you for 12 hours every night and all weekend.
This guide shows you how to build a fully automated, tag-driven scheduler:
- β
Tag-based β Only instances tagged
AutoStop=trueare affected - β Serverless β Runs as an AWS Lambda function (no EC2 needed to manage EC2)
- β EventBridge-triggered β Cron-driven, zero manual intervention
- β Safe β Won’t touch instances in wrong states (e.g., already stopped)
- β Logged β Every action is recorded in CloudWatch Logs
Architecture Diagram
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β AWS Cloud β
β β
β βββββββββββββββββββ ββββββββββββββββββββββββ β
β β EventBridge β β Lambda Function β β
β β β β β β
β β cron(0 20 * *) βββββββββββΊβ lambda_handler() β β
β β (8 PM Stop) β β β β
β β β β 1. Get current hour β β
β β cron(0 8 * *) βββββββββββΊβ 2. Decide: stop/startβ β
β β (8 AM Start) β β 3. Filter by tag β β
β βββββββββββββββββββ β 4. Call EC2 API β β
β ββββββββββββ¬ββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββββ β
β β EC2 Instances β β
β β [AutoStop=true] β β
β β β β
β β dev-server-01 β β
β β staging-api-02 β β
β ββββββββββββββββββββ β
β β
β ββββββββββββββββββββββββββββββββ β
β β CloudWatch Logs β β
β β β "Stopped instances: [...]"β β
β ββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Prerequisites
Before you begin, make sure you have the following in place:
| Requirement | Details |
|---|---|
| AWS Account | With permissions to manage EC2, Lambda, EventBridge, and IAM |
| Python 3.9+ | For local development and testing |
| boto3 | AWS SDK for Python (pip install boto3) |
| AWS CLI | Configured with aws configure |
| IAM Role | A Lambda execution role with EC2 and logging permissions |
How It Works
The solution has three components that work together:
Tag on Instance β EventBridge Cron β Lambda Function β EC2 Action
(AutoStop=true) (fires at 8AM/PM) (decides stop/start) (API call)
- You tag your EC2 instances with
AutoStop=true - EventBridge fires the Lambda at 8 AM and 8 PM (UTC) every day
- Lambda checks the current UTC hour to decide whether to stop or start
- Lambda calls the EC2 API with only the instances matching the tag + correct state
- CloudWatch Logs records the result of every run
Step 1 β Tag Your EC2 Instances
Before writing any code, tag the EC2 instances you want to auto-schedule.
Via AWS Console
- Go to EC2 β Instances
- Select the instance you want to schedule
- Click the Tags tab β Manage tags
- Add a new tag:
- Key:
AutoStop - Value:
true
- Key:
- Click Save
Via AWS CLI
aws ec2 create-tags \
--resources i-0123456789abcdef0 \
--tags Key=AutoStop,Value=true
Via boto3 (Python)
import boto3
ec2 = boto3.client('ec2')
ec2.create_tags(
Resources=['i-0123456789abcdef0', 'i-0abcdef1234567890'],
Tags=[{'Key': 'AutoStop', 'Value': 'true'}]
)
β οΈ Important: The tag value is case-sensitive. Use lowercase
true, notTrueorTRUE, to match the Lambda filter.
Step 2 β The Lambda Function (Deep Dive)
Let’s walk through every section of the Lambda function in detail.
Imports & Logger Setup
import boto3
import logging
from datetime import datetime
logger = logging.getLogger()
logger.setLevel(logging.INFO)
What each import does:
| Import | Purpose |
|---|---|
boto3 | The AWS SDK for Python β lets us call EC2 and other AWS APIs |
logging | Python’s built-in logging module; outputs go to CloudWatch Logs automatically in Lambda |
datetime | Used to get the current UTC hour to decide which action to take |
Why logging instead of print()?
In Lambda, print() works, but logging is preferred because:
- It includes timestamps and log levels (
INFO,WARNING,ERROR) - It integrates cleanly with CloudWatch Logs filtering
- You can control verbosity with
setLevel()
get_tagged_instances() β Fetching the Right Instances
def get_tagged_instances(ec2_client, tag_key, tag_value, state):
"""Fetch EC2 instances with specific tag and state."""
response = ec2_client.describe_instances(
Filters=[
{'Name': f'tag:{tag_key}', 'Values': [tag_value]},
{'Name': 'instance-state-name', 'Values': [state]}
]
)
instance_ids = [
instance['InstanceId']
for reservation in response['Reservations']
for instance in reservation['Instances']
]
return instance_ids
Parameter breakdown:
| Parameter | Type | Description |
|---|---|---|
ec2_client | boto3 client | The EC2 client object used to make API calls |
tag_key | str | The tag key to filter by β e.g., "AutoStop" |
tag_value | str | The tag value to match β e.g., "true" |
state | str | Only return instances in this state β "running" or "stopped" |
Why filter by both tag AND state?
This is a key safety feature. Without the state filter:
- At 8 PM, you might try to stop already-stopped instances β AWS API error
- At 8 AM, you might try to start already-running instances β wasted API calls
By passing state='running' when stopping and state='stopped' when starting, you only get instances that actually need the action.
Understanding the nested list comprehension:
AWS describe_instances returns a deeply nested response:
{
"Reservations": [
{
"Instances": [
{ "InstanceId": "i-001", ... },
{ "InstanceId": "i-002", ... }
]
},
{
"Instances": [
{ "InstanceId": "i-003", ... }
]
}
]
}
The double loop flattens this structure:
instance_ids = [
instance['InstanceId'] # β grab the ID
for reservation in response['Reservations'] # β outer loop: each reservation
for instance in reservation['Instances'] # β inner loop: each instance
]
# Result: ['i-001', 'i-002', 'i-003']
lambda_handler() β The Entry Point
def lambda_handler(event, context):
ec2 = boto3.client('ec2')
current_hour = datetime.utcnow().hour
action = 'stop' if current_hour == 20 else 'start'
event and context:
eventβ The data EventBridge sends when it triggers this function (we don’t use it here, but it’s required by Lambda’s signature)contextβ Runtime info like function name, remaining time, etc. (also unused here)
Why datetime.utcnow().hour?
Lambda always runs in UTC regardless of your AWS region. EventBridge cron expressions also use UTC. Using .utcnow() ensures the hour check is consistent and reliable everywhere.
current_hour = 20 β action = 'stop' (8 PM UTC)
current_hour = 8 β action = 'start' (8 AM UTC)
current_hour = anything else β action = 'start' (safe default)
π‘ Tip: If your team works in IST (UTC+5:30), 8 PM IST = 14:30 UTC and 8 AM IST = 02:30 UTC. Adjust your EventBridge cron accordingly, not the Python code.
The stop block:
if action == 'stop':
instance_ids = get_tagged_instances(ec2, 'AutoStop', 'true', 'running')
if instance_ids:
ec2.stop_instances(InstanceIds=instance_ids)
logger.info(f"Stopped instances: {instance_ids}")
else:
logger.info("No running instances with AutoStop=true found.")
- Fetches only running instances with the tag
- Calls
stop_instances()β this initiates a graceful OS shutdown (like pressing the power button) - Logs the result either way β important for debugging “did it run?”
The start block:
elif action == 'start':
instance_ids = get_tagged_instances(ec2, 'AutoStop', 'true', 'stopped')
if instance_ids:
ec2.start_instances(InstanceIds=instance_ids)
logger.info(f"Started instances: {instance_ids}")
else:
logger.info("No stopped instances with AutoStop=true found.")
- Fetches only stopped instances with the tag
- Calls
start_instances()β initiates boot sequence - Note: Instances take 1β3 minutes to fully start after this call returns
The return value:
return {
'statusCode': 200,
'action': action,
'instances_affected': instance_ids if instance_ids else []
}
Lambda doesn’t need a return value when triggered by EventBridge, but returning structured data is helpful for:
- Manual test invocations in the console
- Step Functions or other orchestrators wrapping this Lambda
- Debugging with CloudWatch Logs Insights
Step 3 β Create the EventBridge Schedule Rules
Understanding Cron Syntax on AWS
AWS EventBridge uses a 6-field cron format (unlike the standard 5-field Unix cron):
cron(Minutes Hours Day-of-month Month Day-of-week Year)
| Expression | Meaning |
|---|---|
cron(0 20 * * ? *) | Every day at 8:00 PM UTC |
cron(0 8 * * ? *) | Every day at 8:00 AM UTC |
cron(0 20 ? * MON-FRI *) | Weekdays only at 8 PM UTC |
β οΈ The
?in day-of-month or day-of-week means “no specific value” β required when the other field is set. AWS requires this for valid cron expressions.
Creating Rules via boto3
import boto3
events = boto3.client('events')
# ββ Stop rule: fires every day at 8 PM UTC ββββββββββββββββββ
events.put_rule(
Name='StopDevInstances',
ScheduleExpression='cron(0 20 * * ? *)',
State='ENABLED',
Description='Stop EC2 instances tagged AutoStop=true at 8 PM UTC'
)
# ββ Start rule: fires every day at 8 AM UTC βββββββββββββββββ
events.put_rule(
Name='StartDevInstances',
ScheduleExpression='cron(0 8 * * ? *)',
State='ENABLED',
Description='Start EC2 instances tagged AutoStop=true at 8 AM UTC'
)
Connecting Rules to the Lambda Function
After creating the rules, you need to add the Lambda as a target for each rule:
lambda_arn = 'arn:aws:lambda:us-east-1:123456789012:function:EC2AutoScheduler'
# Add target to Stop rule
events.put_targets(
Rule='StopDevInstances',
Targets=[
{
'Id': 'EC2StopTarget',
'Arn': lambda_arn
}
]
)
# Add target to Start rule
events.put_targets(
Rule='StartDevInstances',
Targets=[
{
'Id': 'EC2StartTarget',
'Arn': lambda_arn
}
]
)
Grant EventBridge Permission to Invoke Lambda
lambda_client = boto3.client('lambda')
lambda_client.add_permission(
FunctionName='EC2AutoScheduler',
StatementId='AllowEventBridgeStop',
Action='lambda:InvokeFunction',
Principal='events.amazonaws.com',
SourceArn='arn:aws:events:us-east-1:123456789012:rule/StopDevInstances'
)
lambda_client.add_permission(
FunctionName='EC2AutoScheduler',
StatementId='AllowEventBridgeStart',
Action='lambda:InvokeFunction',
Principal='events.amazonaws.com',
SourceArn='arn:aws:events:us-east-1:123456789012:rule/StartDevInstances'
)
Step 4 β IAM Permissions Required
Your Lambda function needs an execution role with the following permissions:
Minimum IAM Policy (JSON)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EC2SchedulerPermissions",
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:StartInstances",
"ec2:StopInstances"
],
"Resource": "*"
},
{
"Sid": "CloudWatchLogsPermissions",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
}
]
}
π Security tip: For production, restrict the
ec2:StartInstancesandec2:StopInstancesactions to specific instance ARNs or by tag condition usingaws:ResourceTagcondition keys.
Scoped Policy with Tag Condition (Recommended)
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:StartInstances",
"ec2:StopInstances"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"ec2:ResourceTag/AutoStop": "true"
}
}
},
{
"Effect": "Allow",
"Action": "ec2:DescribeInstances",
"Resource": "*"
}
]
}
Step 5 β Deploy the Lambda Function
Option A: AWS Console (Quickest)
- Go to Lambda β Create function
- Choose Author from scratch
- Function name:
EC2AutoScheduler - Runtime: Python 3.12
- Execution role: Use the IAM role you created in Step 4
- Paste the full Lambda code into the inline editor
- Click Deploy
- Set Timeout to at least
30 seconds(Configuration β General configuration)
Option B: AWS CLI
# Package the Lambda code
zip function.zip lambda_function.py
# Create the function
aws lambda create-function \
--function-name EC2AutoScheduler \
--runtime python3.12 \
--role arn:aws:iam::123456789012:role/EC2SchedulerRole \
--handler lambda_function.lambda_handler \
--zip-file fileb://function.zip \
--timeout 30
Option C: boto3 Script
import boto3
import zipfile
import io
# Read and zip the Lambda source
with open('lambda_function.py', 'rb') as f:
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
zf.writestr('lambda_function.py', f.read())
zip_bytes = zip_buffer.getvalue()
lambda_client = boto3.client('lambda')
lambda_client.create_function(
FunctionName='EC2AutoScheduler',
Runtime='python3.12',
Role='arn:aws:iam::123456789012:role/EC2SchedulerRole',
Handler='lambda_function.lambda_handler',
Code={'ZipFile': zip_bytes},
Timeout=30
)
Step 6 β Verify It Works
Manual Test via Console
- Open your Lambda function in the AWS Console
- Click Test β Create new test event
- Use any JSON payload (e.g.,
{}) β our handler ignores the event body - Click Test and check the output
Expected output at 8 PM UTC:
{
"statusCode": 200,
"action": "stop",
"instances_affected": ["i-0123456789abcdef0"]
}
Manual Test via CLI
aws lambda invoke \
--function-name EC2AutoScheduler \
--payload '{}' \
--cli-binary-format raw-in-base64-out \
response.json
cat response.json
Check CloudWatch Logs
- Go to CloudWatch β Log groups
- Find
/aws/lambda/EC2AutoScheduler - Open the latest log stream
You should see entries like:
[INFO] 2024-01-15T20:00:03Z Stopped instances: ['i-0abc123', 'i-0def456']
Complete Code
lambda_function.py
import boto3
import logging
from datetime import datetime
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def get_tagged_instances(ec2_client, tag_key, tag_value, state):
"""Fetch EC2 instances with specific tag and state."""
response = ec2_client.describe_instances(
Filters=[
{'Name': f'tag:{tag_key}', 'Values': [tag_value]},
{'Name': 'instance-state-name', 'Values': [state]}
]
)
instance_ids = [
instance['InstanceId']
for reservation in response['Reservations']
for instance in reservation['Instances']
]
return instance_ids
def lambda_handler(event, context):
"""
Lambda handler triggered by EventBridge schedule:
- 8 PM UTC: stop instances tagged AutoStop=true
- 8 AM UTC: start instances tagged AutoStop=true
"""
ec2 = boto3.client('ec2')
current_hour = datetime.utcnow().hour
action = 'stop' if current_hour == 20 else 'start'
if action == 'stop':
instance_ids = get_tagged_instances(ec2, 'AutoStop', 'true', 'running')
if instance_ids:
ec2.stop_instances(InstanceIds=instance_ids)
logger.info(f"Stopped instances: {instance_ids}")
else:
logger.info("No running instances with AutoStop=true found.")
elif action == 'start':
instance_ids = get_tagged_instances(ec2, 'AutoStop', 'true', 'stopped')
if instance_ids:
ec2.start_instances(InstanceIds=instance_ids)
logger.info(f"Started instances: {instance_ids}")
else:
logger.info("No stopped instances with AutoStop=true found.")
return {
'statusCode': 200,
'action': action,
'instances_affected': instance_ids if instance_ids else []
}
setup_eventbridge.py
import boto3
events = boto3.client('events')
# Stop rule at 8 PM UTC
events.put_rule(
Name='StopDevInstances',
ScheduleExpression='cron(0 20 * * ? *)',
State='ENABLED',
Description='Stop EC2 instances tagged AutoStop=true at 8 PM UTC'
)
# Start rule at 8 AM UTC
events.put_rule(
Name='StartDevInstances',
ScheduleExpression='cron(0 8 * * ? *)',
State='ENABLED',
Description='Start EC2 instances tagged AutoStop=true at 8 AM UTC'
)
print("EventBridge rules created successfully.")
Common Errors & Fixes
| Error | Cause | Fix |
|---|---|---|
UnauthorizedOperation: describe_instances | IAM role missing ec2:DescribeInstances | Add the permission to the Lambda execution role |
InvalidInstanceID.NotFound | Instance ID doesn’t exist in the region | Ensure the boto3 client region matches where instances are |
| No instances affected | Tag value mismatch | Check tag is exactly AutoStop = true (lowercase) |
Task timed out after 3.00 seconds | Default 3s timeout too short | Increase Lambda timeout to 30s in Configuration |
| Lambda not triggered | EventBridge target not linked to Lambda | Add Lambda as a target in the EventBridge rule |
AccessDeniedException on EventBridge | Lambda resource policy missing | Run add_permission() to grant EventBridge invoke access |
Cost Savings Estimate
Stopping instances 12 hours per night + full weekends:
| Hours saved per month | Instance type | On-Demand price | Monthly savings |
|---|---|---|---|
| ~372 hrs (12 hrs/night + weekends) | t3.medium | ~$0.0416/hr | ~$15.50/instance |
| ~372 hrs | m5.large | ~$0.096/hr | ~$35.70/instance |
| ~372 hrs | c5.xlarge | ~$0.17/hr | ~$63.24/instance |
For a team of 5 developers each with a dev EC2 instance, that’s $75β$315/month saved on just this one automation.
Extending the Solution
Here are some ideas to build on this foundation:
π Weekday-only schedules
Change the EventBridge cron to cron(0 20 ? * MON-FRI *) so instances stay running over the weekend (or vice versa).
π Multiple timezones
Use separate Lambda functions or check tags like AutoStopTimezone=IST to handle teams in different regions.
π¬ SNS Notifications Add an SNS publish call after stopping/starting to email your team a daily summary.
π DynamoDB state tracking Store instance start/stop history in DynamoDB for cost reporting and audit trails.
π Override tag
Add an AutoStopOverride=true tag that skips the schedule on a specific day (e.g., when working late).
π Infrastructure as Code Migrate the entire setup to Terraform or AWS CDK for repeatable, version-controlled deployments.
Summary
| Component | What It Does |
|---|---|
EC2 Tag (AutoStop=true) | Marks which instances to manage |
get_tagged_instances() | Safely fetches instances by tag + state |
lambda_handler() | Decides stop/start based on current UTC hour |
| EventBridge Cron Rules | Fires the Lambda at 8 AM and 8 PM daily |
| IAM Role | Grants Lambda permission to describe/stop/start EC2 |
| CloudWatch Logs | Records every action for auditing and debugging |
With this solution running, your non-production EC2 instances automatically hibernate each night and wake up each morning β saving costs, reducing your AWS bill, and requiring zero manual intervention.
Built with β€οΈ using AWS Lambda, EventBridge, and boto3
Add More Questions to This Guide
Know questions that should be here? Share them and help the community!
Open Google Form