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

ComponentRoleKey Detail
EventBridge SchedulerTriggers Lambda on cron scheduleTimezone-aware, no CloudWatch Events needed
Lambda FunctionCalls EC2 start/stop APIOne function handles both actions via input payload
IAM Role (Lambda)Authorizes EC2 API callsScoped to specific instance IDs
IAM Role (Scheduler)Allows Scheduler to invoke LambdaSeparate from Lambda execution role
Stop Schedule7 PM local time, weekdayscron(0 19 ? * MON-FRI *) in your timezone
Start Schedule9 AM local time, weekdayscron(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.

graph LR Sched["EventBridge Scheduler
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"]
  1. EventBridge Scheduler fires at the configured cron time in your specified timezone.
  2. Scheduler assumes its IAM role and invokes the Lambda function with a JSON payload containing the action (start or stop) and instance ID.
  3. Lambda receives the event, parses the action, and calls the EC2 API using its own execution role.
  4. EC2 transitions the instance to the target state (stopping or pending).

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-window with OFF like 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.

graph TD Fire["Scheduler Fires"] --> InvokeAttempt["Invoke Lambda"] InvokeAttempt -->|"Scheduler role missing
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"]
  1. Scheduler fires and attempts to invoke Lambda using its role.
  2. If the Scheduler role lacks lambda:InvokeFunction, the invocation is rejected — Lambda never receives the event.
  3. If Lambda receives the event but the execution role lacks ec2:StopInstances, Lambda throws an AccessDeniedException — visible in CloudWatch Logs.
  4. 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

TermDefinition
EventBridge SchedulerA standalone AWS scheduling service that invokes targets on cron or rate expressions, with native timezone support. Distinct from EventBridge Rules.
Lambda Execution RoleThe IAM role assumed by Lambda during function execution. Grants permissions to call other AWS services (e.g., EC2 API).
Scheduler Invocation RoleThe IAM role assumed by EventBridge Scheduler when invoking a target. Required separately from the Lambda execution role.
Flexible Time WindowAn 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.

Related Posts

Comments

Popular posts from this blog

EC2 No Internet Access in Custom VPC: Fix Internet Gateway and Route Table

EC2 SSH Connection Timeout: Which Security Group Rules to Check

Difference Between IAM User and IAM Role: Which One Should Your EC2 Use?