Automate AWS with EventBridge Scheduler and Lambda: Stop and Start EC2 on a Schedule (Terraform)
Scheduled automation on AWS #
A very common AWS automation: turn off development and test instances at night and on weekends. An instance running 50 hours a week instead of 168 costs about 70 percent less. This tutorial uses EventBridge Scheduler to call a Lambda function that stops or starts every instance with a given tag.
EventBridge Scheduler (cron) ──► Lambda (boto3) ──► EC2 stop / start
20:00 stop, 07:00 start tag Schedule=office-hours
Tag the instances #
resource "aws_instance" "dev" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.small"
tags = {
Name = "dev-app"
Environment = "dev"
Schedule = "office-hours"
}
}The function #
import boto3
ec2 = boto3.client("ec2")
def handler(event, context):
action = event["action"] # "start" or "stop"
states = ["stopped"] if action == "start" else ["running"]
reservations = ec2.describe_instances(Filters=[
{"Name": "tag:Schedule", "Values": ["office-hours"]},
{"Name": "instance-state-name", "Values": states},
])["Reservations"]
ids = [i["InstanceId"] for r in reservations for i in r["Instances"]]
if not ids:
return {"action": action, "instances": []}
if action == "start":
ec2.start_instances(InstanceIds=ids)
else:
ec2.stop_instances(InstanceIds=ids)
return {"action": action, "instances": ids}Package and deploy the function #
data "archive_file" "scheduler" {
type = "zip"
source_file = "${path.module}/src/ec2_scheduler.py"
output_path = "${path.module}/build/ec2_scheduler.zip"
}
data "aws_iam_policy_document" "lambda_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "ec2_control" {
statement {
actions = ["ec2:DescribeInstances"]
resources = ["*"]
}
statement {
actions = ["ec2:StartInstances", "ec2:StopInstances"]
resources = ["arn:aws:ec2:*:*:instance/*"]
condition {
test = "StringEquals"
variable = "aws:ResourceTag/Schedule"
values = ["office-hours"]
}
}
}
resource "aws_iam_role" "scheduler_lambda" {
name = "ditwl-ec2-scheduler"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
resource "aws_iam_role_policy" "ec2_control" {
role = aws_iam_role.scheduler_lambda.id
policy = data.aws_iam_policy_document.ec2_control.json
}
resource "aws_iam_role_policy_attachment" "logs" {
role = aws_iam_role.scheduler_lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_lambda_function" "ec2_scheduler" {
function_name = "ditwl-ec2-scheduler"
role = aws_iam_role.scheduler_lambda.arn
runtime = "python3.12"
handler = "ec2_scheduler.handler"
filename = data.archive_file.scheduler.output_path
source_code_hash = data.archive_file.scheduler.output_base64sha256
timeout = 60
}The function can only start and stop instances that carry the tag, following least privilege (IAM roles). Package details are in the Lambda tutorial.
The schedules #
EventBridge Scheduler needs a role that it assumes to invoke the function:
data "aws_iam_policy_document" "scheduler_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["scheduler.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "invoke" {
statement {
actions = ["lambda:InvokeFunction"]
resources = [aws_lambda_function.ec2_scheduler.arn]
}
}
resource "aws_iam_role" "scheduler" {
name = "ditwl-scheduler-invoke"
assume_role_policy = data.aws_iam_policy_document.scheduler_assume.json
}
resource "aws_iam_role_policy" "invoke" {
role = aws_iam_role.scheduler.id
policy = data.aws_iam_policy_document.invoke.json
}
locals {
schedules = {
stop = {
expression = "cron(0 20 ? * MON-FRI *)"
action = "stop"
}
start = {
expression = "cron(0 7 ? * MON-FRI *)"
action = "start"
}
}
}
resource "aws_scheduler_schedule" "ec2" {
for_each = local.schedules
name = "ditwl-ec2-${each.key}"
schedule_expression = each.value.expression
schedule_expression_timezone = "Europe/Madrid"
flexible_time_window {
mode = "OFF"
}
target {
arn = aws_lambda_function.ec2_scheduler.arn
role_arn = aws_iam_role.scheduler.arn
input = jsonencode({ action = each.value.action })
}
}- The cron format has six fields (minutes, hours, day of month, month, day of week, year), and one of the day fields must be
?. schedule_expression_timezonemakes the schedule follow local time including daylight saving, something classic EventBridge rules cannot do (they use UTC).- The
for_eachcreates the two schedules from one map.
Test it #
$ aws lambda invoke --function-name ditwl-ec2-scheduler \
--payload '{"action":"stop"}' --cli-binary-format raw-in-base64-out out.json
$ cat out.json
{"action": "stop", "instances": ["i-0abc123def4567890"]}
Add a CloudWatch alarm on the function's Errors metric so you notice when it fails.
Other automations with the same pattern #
- Stop or snapshot development RDS databases.
- Scale an Auto Scaling group to zero at night.
- Delete old snapshots, unattached EBS volumes or untagged resources.
- Run a Step Function or an ECS task every night.
- React to events instead of time: for example tag new instances automatically using an EventBridge rule on EC2 state changes.
Ready-made alternatives #
AWS offers the Instance Scheduler on AWS solution, and Systems Manager Automation runbooks can start and stop instances without custom code. Writing your own is simple and flexible, and is a good first serverless project.
Savings #
A t3.small costs a few dollars a month, so the saving matters when you have dozens of instances. Combine it with the checklist in AWS cost optimization. Stopped instances still pay for their EBS volumes.