How to Schedule EC2 Instance Stop and Start with EventBridge Scheduler and Lambda
Every dev team has that one EC2 instance running overnight doing absolutely nothing — burning compute budget while everyone sleeps. Scheduling an EC2 instance to stop at night and start in the morning is one of the highest-ROI automation tasks you can implement in under an hour, and EventBridge Scheduler with Lambda is the cleanest way to do it without third-party tooling.
TL;DR: EC2 Schedule Stop/Start Setup
| Component | Role | Key Detail |
|---|---|---|
| EventBridge Scheduler | Triggers Lambda on cron schedule | Timezone-aware, no CloudWatch Events needed |
| Lambda Function | Calls EC2 start/stop API | One function handles both actions via input payload |
| IAM Role (Lambda) | Authorizes EC2 API calls | Scoped to specific instance IDs |
| IAM Role (Scheduler) | Allows Scheduler to invoke Lambda | Separate from Lambda execution role |
| Stop Schedule | 7 PM local time, weekdays | cron(0 19 ? * MON-FRI *) in your timezone |
| Start Schedule | 9 AM local time, weekdays | cron(0 9 ? * MON-FRI *) in your timezone |
How EventBridge Scheduler and Lambda Work Together for EC2 Scheduling
EventBridge Scheduler is a standalone scheduler service — distinct from EventBridge rules — that supports timezone-aware cron and rate expressions. When a schedule fires, it invokes a target directly. Lambda is the target here, receiving a JSON payload that tells it which action to perform and which instance to act on.
Lambda then calls the EC2 API (start_instances or stop_instances) using the permissions granted by its execution role. The Scheduler itself needs a separate IAM role with permission to invoke the Lambda function — these two roles are independent and serve different principals.
cron(0 19 ? * MON-FRI *)"] -->|"Assumes Scheduler Role"| SchedRole["IAM Role
scheduler.amazonaws.com"] SchedRole -->|"lambda:InvokeFunction"| Lambda["Lambda Function
ec2-start-stop-scheduler"] Lambda -->|"Assumes Execution Role"| LambdaRole["IAM Role
lambda.amazonaws.com"] LambdaRole -->|"ec2:StopInstances
ec2:StartInstances"| EC2["EC2 Instance
i-0abcdef1234567890"] Lambda -->|"logs:PutLogEvents"| CWL["CloudWatch Logs"]
- EventBridge Scheduler fires at the configured cron time in your specified timezone.
- Scheduler assumes its IAM role and invokes the Lambda function with a JSON payload containing the action (
startorstop) and instance ID. - Lambda receives the event, parses the action, and calls the EC2 API using its own execution role.
- EC2 transitions the instance to the target state (
stoppingorpending).
Step 1: Create the Lambda Execution Role
Before writing a single line of Lambda code, lock down the IAM role. The execution role needs EC2 start/stop permissions scoped to the specific instance, plus basic Lambda logging permissions. Scoping to a specific instance ID is supported for ec2:StartInstances and ec2:StopInstances — do it.
🔽 Click to expand: Lambda execution role trust policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
🔽 Click to expand: Lambda execution role permission policy
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowEC2StartStop",
"Effect": "Allow",
"Action": [
"ec2:StartInstances",
"ec2:StopInstances"
],
"Resource": "arn:aws:ec2:us-east-1:123456789012:instance/i-0abcdef1234567890"
},
{
"Sid": "AllowCloudWatchLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/*"
}
]
}
Create the role and attach the policy via CLI. Replace the instance ID and account ID with your actual values.
# Create the trust policy file first (save as trust-policy.json)
# Then create the role
aws iam create-role \
--role-name ec2-scheduler-lambda-role \
--assume-role-policy-document file://trust-policy.json \
--region us-east-1
# Attach the permission policy (save permissions as ec2-scheduler-policy.json)
aws iam put-role-policy \
--role-name ec2-scheduler-lambda-role \
--policy-name EC2StartStopPolicy \
--policy-document file://ec2-scheduler-policy.json
Step 2: Deploy the Lambda Function
One function handles both start and stop — the action is passed in the event payload from the Scheduler. This keeps deployment simple: two schedules, one function, different payloads. The function reads the action key from the event and routes accordingly.
🔽 Click to expand: Lambda function code (Python 3.12)
import boto3
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
ec2 = boto3.client('ec2')
def lambda_handler(event, context):
action = event.get('action')
instance_id = event.get('instance_id')
if not action or not instance_id:
raise ValueError('Missing required keys: action and instance_id')
if action == 'stop':
response = ec2.stop_instances(InstanceIds=[instance_id])
logger.info('Stop initiated for %s: %s', instance_id,
response['StoppingInstances'][0]['CurrentState']['Name'])
elif action == 'start':
response = ec2.start_instances(InstanceIds=[instance_id])
logger.info('Start initiated for %s: %s', instance_id,
response['StartingInstances'][0]['CurrentState']['Name'])
else:
raise ValueError(f'Unknown action: {action}')
return {'status': 'ok', 'action': action, 'instance_id': instance_id}
Package and deploy the function. The --role ARN must match the role created in Step 1.
# Package the function
zip function.zip lambda_function.py
# Deploy to Lambda
aws lambda create-function \
--function-name ec2-start-stop-scheduler \
--runtime python3.12 \
--role arn:aws:iam::123456789012:role/ec2-scheduler-lambda-role \
--handler lambda_function.lambda_handler \
--zip-file fileb://function.zip \
--timeout 30 \
--region us-east-1
Step 3: Create the EventBridge Scheduler IAM Role
EventBridge Scheduler needs its own IAM role to invoke Lambda — this is separate from the Lambda execution role. The Scheduler assumes this role when firing, and the role must have lambda:InvokeFunction permission on your specific function. Forgetting this role is the most common reason schedules silently fail to trigger.
🔽 Click to expand: Scheduler trust policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "scheduler.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
🔽 Click to expand: Scheduler permission policy
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowLambdaInvoke",
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:ec2-start-stop-scheduler"
}
]
}
# Create the Scheduler role (save trust policy as scheduler-trust.json)
aws iam create-role \
--role-name eventbridge-scheduler-lambda-role \
--assume-role-policy-document file://scheduler-trust.json \
--region us-east-1
# Attach the invoke permission (save as scheduler-policy.json)
aws iam put-role-policy \
--role-name eventbridge-scheduler-lambda-role \
--policy-name InvokeLambdaPolicy \
--policy-document file://scheduler-policy.json
Step 4: Create the Stop and Start Schedules in EventBridge Scheduler
Two schedules: one fires at 7 PM to stop the instance, one fires at 9 AM to start it. Both are scoped to weekdays only. The --schedule-expression-timezone parameter is what makes EventBridge Scheduler genuinely useful here — it handles DST transitions automatically so your 7 PM stop stays at 7 PM year-round.
The --target input payload is where you pass the action and instance ID to Lambda. This is the JSON the Scheduler sends as the Lambda event object.
# Create the STOP schedule (7 PM weekdays)
aws scheduler create-schedule \
--name ec2-dev-stop \
--schedule-expression 'cron(0 19 ? * MON-FRI *)' \
--schedule-expression-timezone 'America/New_York' \
--target '{
"Arn": "arn:aws:lambda:us-east-1:123456789012:function:ec2-start-stop-scheduler",
"RoleArn": "arn:aws:iam::123456789012:role/eventbridge-scheduler-lambda-role",
"Input": "{\"action\": \"stop\", \"instance_id\": \"i-0abcdef1234567890\"}"
}' \
--flexible-time-window '{ "Mode": "OFF" }' \
--region us-east-1
# Create the START schedule (9 AM weekdays)
aws scheduler create-schedule \
--name ec2-dev-start \
--schedule-expression 'cron(0 9 ? * MON-FRI *)' \
--schedule-expression-timezone 'America/New_York' \
--target '{
"Arn": "arn:aws:lambda:us-east-1:123456789012:function:ec2-start-stop-scheduler",
"RoleArn": "arn:aws:iam::123456789012:role/eventbridge-scheduler-lambda-role",
"Input": "{\"action\": \"start\", \"instance_id\": \"i-0abcdef1234567890\"}"
}' \
--flexible-time-window '{ "Mode": "OFF" }' \
--region us-east-1
Think of--flexible-time-windowwithOFFlike a hard alarm — it fires at exactly the scheduled time. Setting a flexible window (e.g., 15 minutes) lets Scheduler fire within a window, which is useful for load distribution across many schedules but unnecessary here.
Verifying the Setup Works
Don't wait until 7 PM to find out something is broken. Test the Lambda function directly by invoking it with a synthetic payload. This confirms the IAM permissions, the EC2 API call, and the function logic all work before the first scheduled trigger fires.
# Test the stop action manually
aws lambda invoke \
--function-name ec2-start-stop-scheduler \
--payload '{ "action": "stop", "instance_id": "i-0abcdef1234567890" }' \
--cli-binary-format raw-in-base64-out \
--region us-east-1 \
response.json
cat response.json
# Verify the instance state changed
aws ec2 describe-instances \
--instance-ids i-0abcdef1234567890 \
--query 'Reservations[0].Instances[0].State.Name' \
--output text \
--region us-east-1
# Check Lambda logs for execution details
aws logs tail /aws/lambda/ec2-start-stop-scheduler \
--follow \
--region us-east-1
Confirm the schedules are registered and in the expected state:
aws scheduler list-schedules \
--region us-east-1 \
--query 'Schedules[?contains(Name, `ec2-dev`)].{Name:Name,State:State,Expression:ScheduleExpression}'
A Real Failure Pattern: The Silent Schedule That Never Fired
The symptom: schedules show as ENABLED in the console, Lambda has no errors in CloudWatch, but the instance keeps running at 7 PM. After checking Lambda permissions, the function code, and the cron expression — all fine — the actual cause was that the Scheduler role was created but the lambda:InvokeFunction permission was attached to the wrong resource ARN. The policy had the Lambda function ARN from a different region.
EventBridge Scheduler does not surface invocation failures as schedule errors in the console by default. The schedule fires, the invocation fails silently, and nothing in the schedule's status reflects that. To catch this, check the Scheduler's dead-letter queue configuration or enable CloudWatch metrics for the schedule group.
# Check if a DLQ is configured on the schedule
aws scheduler get-schedule \
--name ec2-dev-stop \
--region us-east-1 \
--query 'Target.DeadLetterConfig'
If the DLQ is not configured and invocations are failing, you have no visibility. Add a DLQ to catch failed invocations — an SQS queue works well here.
lambda:InvokeFunction"| Rejected["Invocation Rejected
No Lambda event received"] InvokeAttempt -->|"Scheduler role OK"| LambdaReceives["Lambda Receives Event"] LambdaReceives -->|"Execution role missing
ec2:StopInstances"| AccessDenied["AccessDeniedException
Visible in CloudWatch Logs"] LambdaReceives -->|"Execution role OK"| EC2Call["EC2 API Call Succeeds"] Rejected -->|"No DLQ configured"| Silent["Silent Failure
No trace anywhere"] Rejected -->|"DLQ configured"| DLQ["Message in SQS DLQ"]
- Scheduler fires and attempts to invoke Lambda using its role.
- If the Scheduler role lacks
lambda:InvokeFunction, the invocation is rejected — Lambda never receives the event. - If Lambda receives the event but the execution role lacks
ec2:StopInstances, Lambda throws anAccessDeniedException— visible in CloudWatch Logs. - Without a DLQ on the Scheduler target, failed invocations from Step 2 leave no trace.
Extending the Setup: Multiple Instances and Instance State Guards
If you have several dev instances to schedule, the cleanest approach is to tag them (e.g., AutoSchedule=true) and have Lambda query by tag rather than hardcoding instance IDs. This requires adding ec2:DescribeInstances to the Lambda execution role — note that ec2:DescribeInstances does not support resource-level restrictions and requires "Resource": "*".
Also worth adding: a state check before calling stop or start. If a scheduled stop fires but the instance is already stopped (e.g., someone shut it down manually), calling stop_instances on an already-stopped instance returns an error. A quick describe_instances call before acting prevents noisy Lambda errors in your logs.
🔽 Click to expand: Lambda with state guard and tag-based targeting
import boto3
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
ec2 = boto3.client('ec2')
def get_instances_by_tag(tag_key, tag_value):
response = ec2.describe_instances(
Filters=[
{'Name': f'tag:{tag_key}', 'Values': [tag_value]},
{'Name': 'instance-state-name', 'Values': ['running', 'stopped']}
]
)
instance_ids = []
for reservation in response['Reservations']:
for instance in reservation['Instances']:
instance_ids.append({
'id': instance['InstanceId'],
'state': instance['State']['Name']
})
return instance_ids
def lambda_handler(event, context):
action = event.get('action')
tag_key = event.get('tag_key', 'AutoSchedule')
tag_value = event.get('tag_value', 'true')
if not action:
raise ValueError('Missing required key: action')
instances = get_instances_by_tag(tag_key, tag_value)
if action == 'stop':
targets = [i['id'] for i in instances if i['state'] == 'running']
if targets:
ec2.stop_instances(InstanceIds=targets)
logger.info('Stopping instances: %s', targets)
else:
logger.info('No running instances to stop')
elif action == 'start':
targets = [i['id'] for i in instances if i['state'] == 'stopped']
if targets:
ec2.start_instances(InstanceIds=targets)
logger.info('Starting instances: %s', targets)
else:
logger.info('No stopped instances to start')
else:
raise ValueError(f'Unknown action: {action}')
return {'status': 'ok', 'action': action, 'targeted': targets if targets else []}
Wrap-Up: EC2 Scheduling with EventBridge Scheduler and Lambda
The setup is four components: a Lambda execution role, a Lambda function, a Scheduler invocation role, and two schedules. The most operationally important detail is the two-role model — Scheduler and Lambda each need their own IAM role, and confusing them is the most common misconfiguration. Add a DLQ to the Scheduler target from day one so failed invocations don't disappear silently.
For teams managing multiple dev environments, the tag-based approach scales cleanly — one function, one set of schedules, any number of instances. Pricing and Lambda invocation limits vary — check the EventBridge Scheduler pricing page and official Scheduler documentation for current details.
Glossary
| Term | Definition |
|---|---|
| EventBridge Scheduler | A standalone AWS scheduling service that invokes targets on cron or rate expressions, with native timezone support. Distinct from EventBridge Rules. |
| Lambda Execution Role | The IAM role assumed by Lambda during function execution. Grants permissions to call other AWS services (e.g., EC2 API). |
| Scheduler Invocation Role | The IAM role assumed by EventBridge Scheduler when invoking a target. Required separately from the Lambda execution role. |
| Flexible Time Window | An EventBridge Scheduler option that allows a schedule to fire within a defined window around the target time, rather than exactly at it. |
| Dead-Letter Queue (DLQ) | An SQS queue configured on a Scheduler target to capture events that failed to be delivered, enabling visibility into silent invocation failures. |
Comments
Post a Comment