# Automate AWS with EventBridge Scheduler and Lambda: Stop and Start EC2 on a Schedule (Terraform)

> Automate AWS tasks with Terraform: schedule a Lambda function with EventBridge Scheduler to stop and start tagged EC2 instances and save costs outside working hours.

- Source: https://www.itwonderlab.com/aws-eventbridge-scheduler-terraform/
- Published: 2026-06-27
- Updated: 2026-06-27
- Author: Javier Ruiz Jiménez (https://www.javierruizjimenez.com/)
- Site: IT Wonder Lab (https://www.itwonderlab.com/)

---

## 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](https://www.itwonderlab.com/aws-eventbridge/) to call a [Lambda](https://www.itwonderlab.com/aws-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

```hcl title="ec2.tf"
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

```python title="src/ec2_scheduler.py"
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

```hcl title="lambda.tf"
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](https://www.itwonderlab.com/aws-terraform-tutorial-aws-iam-roles-policies/)). Package details are in the [Lambda tutorial](https://www.itwonderlab.com/terraform-aws-lambda-api-gateway/).

### The schedules

EventBridge Scheduler needs a role that it assumes to invoke the function:

```hcl title="scheduler.tf"
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_timezone` makes the schedule follow local time including daylight saving, something classic EventBridge rules cannot do (they use UTC).
- The [`for_each`](https://www.itwonderlab.com/terraform-for-each-vs-count/) creates the two schedules from one map.

### Test it

```shell
$ 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](https://www.itwonderlab.com/terraform-aws-cloudwatch-alarms/) on the function's `Errors` metric so you notice when it fails.

### Other automations with the same pattern

- Stop or snapshot development [RDS](https://www.itwonderlab.com/aws-rds/) databases.
- Scale an [Auto Scaling group](https://www.itwonderlab.com/aws-terraform-tutorial-aws-auto-scaling/) to zero at night.
- Delete old snapshots, unattached [EBS](https://www.itwonderlab.com/aws-ebs/) volumes or untagged resources.
- Run a [Step Function](https://www.itwonderlab.com/aws-step-functions/) 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](https://www.itwonderlab.com/aws-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](https://www.itwonderlab.com/aws-cost-optimization-finops/). Stopped instances still pay for their [EBS](https://www.itwonderlab.com/aws-ebs/) volumes.
