FINOPS SAVE 65%

How to Reduce AWS Costs by Scheduling Dev and Staging Resources

By Akshay Ghalme · March 30, 2026 · 12 min read

You can reduce AWS costs for dev and staging environments by 60-65% by automatically stopping EC2 instances and RDS databases at night and on weekends using Lambda and EventBridge. Your non-production resources run 24/7 but your team only works 8 hours a day — that means you are paying for 128 hours of idle time every week. A simple scheduled scaling setup fixes this in under 30 minutes.

Your dev and staging environments are running 24 hours a day, 7 days a week. Your team works maybe 8 hours a day, 5 days a week. That means you are paying for 128 hours of idle time every single week — and those idle hours add up to roughly 65% of your non-production AWS bill.

I noticed this at my own company when I was going through our monthly AWS invoice. We had 4 dev environments and 2 staging setups, each with EC2 instances and RDS databases running around the clock. Nobody was using them between 8 PM and 9 AM. Nobody was using them on weekends. But we were paying for every second of it.

The fix took me about 30 minutes to set up. A Lambda function, two EventBridge cron rules, and a simple tagging convention. Our non-production costs dropped from around $3,200 a month to just under $1,200.

Here is exactly how to do it.

What You Will Build

By the end of this guide, you will have an automated system that:

  • Stops all tagged EC2 instances and RDS databases at a time you choose (for example, 8 PM every weekday)
  • Starts them back up before your team begins work (for example, 8 AM every weekday)
  • Leaves weekends off entirely — no compute costs on Saturday and Sunday
  • Uses tags to control which resources get scheduled — so you never accidentally shut down production

The whole setup uses Lambda, EventBridge, and IAM. No third-party tools, no extra services to pay for.

The Math: How Much You Will Actually Save

Before building anything, let me show you why this is worth your time.

There are 168 hours in a week. If your team works Monday to Friday, 9 AM to 6 PM, that is 45 hours of actual usage. The remaining 123 hours are wasted.

Here is what that looks like in real numbers:

Weekly hours:                    168
Working hours (Mon-Fri, 9-6):     45
Idle hours:                      123

Idle percentage:                  73%

Example monthly cost (before):   $3,200
Savings at 65%:                  $2,080
Monthly cost (after):            $1,120
Annual savings:                  $24,960

The savings are not exactly 73% because you still pay for EBS volumes and RDS storage when instances are stopped. In practice, most teams save between 60% and 65% on the resources they schedule.

If you are running multiple environments — dev, staging, QA, testing — this adds up fast.

Prerequisites

  • An AWS account with permissions to create Lambda functions, IAM roles, and EventBridge rules
  • Terraform 1.5 or later installed on your machine
  • AWS CLI configured with your credentials
  • EC2 instances or RDS databases in a non-production environment you want to schedule

Step 1: Decide on Your Schedule

Before writing any code, figure out when your team actually needs these resources. Talk to your developers. Check CloudWatch metrics for login times if you have them.

For most teams, a reasonable schedule looks like this:

  • Start: 8:00 AM on weekdays (30 minutes before the team arrives)
  • Stop: 8:00 PM on weekdays (a buffer after the last person leaves)
  • Weekends: Everything stays off

In EventBridge cron format, that translates to:

# Start at 8:00 AM IST (2:30 AM UTC), Monday through Friday
cron(30 2 ? * MON-FRI *)

# Stop at 8:00 PM IST (2:30 PM UTC), Monday through Friday
cron(30 14 ? * MON-FRI *)
Important: EventBridge cron expressions use UTC. Convert your local time zone before setting these up, or you will wonder why your instances are starting at 2 AM local time.

Step 2: Tag Your Resources

This is the most important step and the one most people rush through. The Lambda function will only touch resources that have a specific tag. No tag, no scheduling. This is your safety net against accidentally stopping production.

Pick a tag key and value. I use:

Key:   AutoSchedule
Value: true

Add this tag to every EC2 instance and RDS database you want to schedule. You can do it through the console, the CLI, or in your Terraform code:

# In your EC2 instance Terraform config
resource "aws_instance" "dev_app" {
  # ... your existing config ...

  tags = {
    Name         = "dev-app-server"
    Environment  = "dev"
    AutoSchedule = "true"    # This is what the Lambda looks for
  }
}

# Same for RDS
resource "aws_db_instance" "dev_db" {
  # ... your existing config ...

  tags = {
    Name         = "dev-database"
    Environment  = "dev"
    AutoSchedule = "true"
  }
}

Double-check your production resources do NOT have this tag. Go through the console and verify. This is not something you want to get wrong.

Step 3: Create the Lambda Function

The Lambda function is straightforward. It takes an action (start or stop), finds all resources with the AutoSchedule: true tag, and performs that action on each one.

Here is the Python code:

import boto3
import os

def lambda_handler(event, context):
    action = event.get('action', 'stop')
    region = os.environ.get('AWS_REGION', 'ap-south-1')

    ec2 = boto3.client('ec2', region_name=region)
    rds = boto3.client('rds', region_name=region)

    # Handle EC2 instances
    filters = [{'Name': 'tag:AutoSchedule', 'Values': ['true']}]

    if action == 'stop':
        # Find running instances with the tag
        instances = ec2.describe_instances(
            Filters=filters + [{'Name': 'instance-state-name', 'Values': ['running']}]
        )
        instance_ids = []
        for r in instances['Reservations']:
            for i in r['Instances']:
                instance_ids.append(i['InstanceId'])

        if instance_ids:
            ec2.stop_instances(InstanceIds=instance_ids)
            print(f"Stopped EC2: {instance_ids}")

    elif action == 'start':
        # Find stopped instances with the tag
        instances = ec2.describe_instances(
            Filters=filters + [{'Name': 'instance-state-name', 'Values': ['stopped']}]
        )
        instance_ids = []
        for r in instances['Reservations']:
            for i in r['Instances']:
                instance_ids.append(i['InstanceId'])

        if instance_ids:
            ec2.start_instances(InstanceIds=instance_ids)
            print(f"Started EC2: {instance_ids}")

    # Handle RDS instances
    rds_instances = rds.describe_db_instances()
    for db in rds_instances['DBInstances']:
        arn = db['DBInstanceArn']
        tags = rds.list_tags_for_resource(ResourceName=arn)
        tag_dict = {t['Key']: t['Value'] for t in tags['TagList']}

        if tag_dict.get('AutoSchedule') != 'true':
            continue

        if action == 'stop' and db['DBInstanceStatus'] == 'available':
            rds.stop_db_instance(DBInstanceIdentifier=db['DBInstanceIdentifier'])
            print(f"Stopped RDS: {db['DBInstanceIdentifier']}")

        elif action == 'start' and db['DBInstanceStatus'] == 'stopped':
            rds.start_db_instance(DBInstanceIdentifier=db['DBInstanceIdentifier'])
            print(f"Started RDS: {db['DBInstanceIdentifier']}")

    return {'statusCode': 200, 'body': f'{action} completed'}

The function checks the instance state before acting. It will not try to stop something that is already stopped, and it will not try to start something that is already running. This avoids API errors when things get out of sync.

Step 4: Set Up IAM Permissions

The Lambda function needs permission to manage EC2 and RDS. Here is the IAM policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:StartInstances",
        "ec2:StopInstances"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "rds:DescribeDBInstances",
        "rds:ListTagsForResource",
        "rds:StartDBInstance",
        "rds:StopDBInstance"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    }
  ]
}
You can tighten the Resource field to specific ARNs if your security team requires it. Using "*" works fine for most setups but limits the blast radius less.

Step 5: Create EventBridge Rules

You need two EventBridge rules — one to trigger the stop action and one to trigger the start action. Each rule passes a different payload to the same Lambda function.

The stop rule fires at 8 PM on weekdays:

resource "aws_cloudwatch_event_rule" "stop_schedule" {
  name                = "auto-stop-dev-staging"
  description         = "Stop dev and staging resources at night"
  schedule_expression = "cron(30 14 ? * MON-FRI *)"  # 8:00 PM IST
}

resource "aws_cloudwatch_event_target" "stop_target" {
  rule      = aws_cloudwatch_event_rule.stop_schedule.name
  target_id = "StopResources"
  arn       = aws_lambda_function.scheduler.arn
  input     = jsonencode({ "action": "stop" })
}

The start rule fires at 8 AM on weekdays:

resource "aws_cloudwatch_event_rule" "start_schedule" {
  name                = "auto-start-dev-staging"
  description         = "Start dev and staging resources in the morning"
  schedule_expression = "cron(30 2 ? * MON-FRI *)"  # 8:00 AM IST
}

resource "aws_cloudwatch_event_target" "start_target" {
  rule      = aws_cloudwatch_event_rule.start_schedule.name
  target_id = "StartResources"
  arn       = aws_lambda_function.scheduler.arn
  input     = jsonencode({ "action": "start" })
}

Do not forget to grant EventBridge permission to invoke your Lambda:

resource "aws_lambda_permission" "allow_eventbridge_stop" {
  statement_id  = "AllowEventBridgeStop"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.scheduler.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.stop_schedule.arn
}

resource "aws_lambda_permission" "allow_eventbridge_start" {
  statement_id  = "AllowEventBridgeStart"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.scheduler.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.start_schedule.arn
}

Step 6: Test Before You Trust It

Do not just deploy this and walk away. Test it properly.

  1. Invoke the Lambda manually with {"action": "stop"} and verify that only tagged resources stop. Check the console. Confirm production is untouched.
  2. Invoke it again with {"action": "start"} and verify everything comes back up. Check that applications are reachable and databases accept connections.
  3. Check CloudWatch Logs to see which resources the function touched. The print statements in the code will show you exactly what happened.
  4. Monitor for 3 to 5 days to make sure the schedule fires correctly. Set up a CloudWatch alarm if the Lambda errors out.

The first morning after deployment, send a quick message to your team: "Dev environments now auto-start at 8 AM and auto-stop at 8 PM. If you need them outside those hours, start them manually from the console."

Common Mistakes to Avoid

  1. Forgetting to convert time zones. EventBridge uses UTC. I have seen teams accidentally stop their instances during peak hours because they entered their local time directly into the cron expression. Always convert first.
  2. Not tagging resources properly. If you forget the AutoSchedule tag on a new dev instance, it will run 24/7 and you will not notice until the next bill. Add the tag to your Terraform templates so every new resource gets it by default.
  3. Ignoring RDS auto-restart. AWS automatically restarts any RDS instance that has been stopped for 7 days. Since your schedule restarts it every weekday morning, this does not matter during the week. But if your team takes a long holiday, the RDS will restart itself on day 7 and keep running until the next scheduled stop.
  4. Not giving enough startup buffer. RDS takes 5 to 10 minutes to start up. EC2 is faster but your application might need time to boot. Set the start time 15 to 30 minutes before your team arrives.
  5. Scheduling production by mistake. Always double-check your tags. Use a different AWS account for production if possible. At minimum, verify your production resources do not carry the scheduling tag before you deploy.

What About Auto Scaling Groups?

If your dev environments use Auto Scaling Groups instead of standalone EC2 instances, the approach is slightly different. You cannot just stop the instances because the ASG will detect them as unhealthy and launch new ones.

Instead, you set the desired capacity to 0 at night and back to your normal count in the morning. The Lambda function would call update_auto_scaling_group with DesiredCapacity=0 to scale down and the original value to scale back up.

Frequently Asked Questions

How much can I save by stopping EC2 and RDS at night?

If your team works 8 hours a day, 5 days a week, your dev and staging resources sit idle for roughly 128 out of 168 hours each week. That is about 76% of the time. In practice, stopping resources during nights and weekends saves between 60% and 65% on those specific EC2 and RDS costs.

Will stopping an RDS instance lose my data?

No. Stopping an RDS instance is not the same as deleting it. Your data, configurations, and endpoints all stay exactly the same. The instance just stops running and you stop paying for compute charges. Storage charges still apply but those are typically a small fraction of the total RDS cost.

What happens if someone needs the dev environment outside work hours?

They can manually start the instances from the AWS console or CLI whenever needed. The next scheduled event will still fire as normal. Some teams also set up a Slack bot or a simple API Gateway endpoint that lets developers start their environment on demand.

Does AWS automatically restart stopped RDS instances after 7 days?

Yes. AWS automatically restarts any RDS instance that has been stopped for 7 consecutive days. This is an AWS limitation you cannot override. Your scheduled start and stop events handle this automatically because they restart the instance every weekday morning, so the 7-day limit never gets reached during normal work weeks.

Can I use this for production environments?

This approach is designed for non-production environments like dev, staging, QA, and testing. Production environments typically need to run 24/7. For production cost optimization, look into reserved instances, savings plans, right-sizing, and spot instances instead.


Skip the Manual Setup — Use the Terraform Module

Everything I covered in this guide — the Lambda function, the IAM role, the EventBridge rules, the tagging logic — I have packaged into an open-source Terraform module that you can deploy in under 5 minutes.

Instead of writing all of this from scratch, you can use it like this:

module "scheduled_scaling" {
  source = "github.com/akshayghalme/terraform-scheduled-scaling"

  name               = "dev-staging-scheduler"
  start_schedule     = "cron(30 2 ? * MON-FRI *)"   # 8:00 AM IST
  stop_schedule      = "cron(30 14 ? * MON-FRI *)"   # 8:00 PM IST
  resource_tag_key   = "AutoSchedule"
  resource_tag_value = "true"
}

# Then just tag your resources:
# AutoSchedule = "true"

Three lines of Terraform. Tag your resources. Run terraform apply. Done.

The module handles Lambda packaging, IAM permissions, EventBridge rules, CloudWatch logging, and error handling — all the things you would otherwise spend an afternoon wiring up manually.

Get the Terraform Scheduled Scaling module on GitHub →

AG

Akshay Ghalme

AWS DevOps Engineer with 3+ years building production cloud infrastructure. AWS Certified Solutions Architect. Currently managing a multi-tenant SaaS platform serving 1000+ customers.

More Guides & Terraform Modules

Every guide comes with a matching open-source Terraform module you can deploy right away.