Skip to content

How to Automate Start/Stop Scheduling for Non-Production AWS Resources Using EventBridge Scheduler and SSM Automation

8 minute read
Content level: Intermediate
2

This post describes how to build a fully serverless scheduling solution that starts and stops non-production AWS resources on a cron schedule. The solution uses Amazon EventBridge Scheduler and AWS Systems Manager (SSM) Automation to achieve up to 70% cost savings with zero custom infrastructure to maintain.

The solution supports EC2, RDS/Aurora, Redshift, EKS Node Groups, and OpenSearch. It uses tag-based targeting, deploys as a reusable Terraform module, and costs $0/month to operate.

Problem

Non-production environments (development, testing, staging) often run 24/7. Teams only use these resources during business hours — approximately 50 of the 168 hours per week. The remaining 118 hours represent wasted spend.

For a fleet of 50 non-production instances, this waste amounts to approximately $29,000 per year.

ScheduleHours Running/WeekMonthly CostSavingsAnnual Savings
24/7 (no scheduling)168h$3,5000%$0
Stop nights only (19:00–07:00)70h$1,470~58%$24,360
Stop nights + weekends50h$1,050~70%$29,400
Nights + weekends + holidays~47h$980~72%$30,240

The challenge: implement scheduling that is low-maintenance, supports multiple resource types, handles overrides, and deploys consistently across accounts.


Solution

We use Amazon EventBridge Scheduler to define cron-based start/stop times, which directly invoke SSM Automation Runbooks. Each runbook discovers tagged resources and executes the appropriate start or stop API calls.

Architecture

EventBridge Scheduler (cron + timezone)
        │
        ▼
SSM Automation Runbook (per resource type)
        │
        ├──▶ Discover resources by tag (Schedule:office-hours)
        ├──▶ Check override tag (KeepRunning:true → skip)
        └──▶ Execute start/stop API call

Why this approach

We evaluated four alternatives before selecting EventBridge Scheduler + SSM Automation:

CriteriaEventBridge + SSMAWS Instance SchedulerCustom LambdaSSM Quick Setup
Setup complexityLowMediumHighLow
Resources supportedAny AWS APIEC2/RDS/ASGAnyEC2/RDS
MaintenanceNoneLowHighNone
Monthly costFree~$5~$1Free
Multi-accountPer-account deployHub-spokeCustom codeOrg-native

We selected EventBridge + SSM because it supports all five target resource types with zero infrastructure to maintain, provides native timezone handling, and includes built-in retry policies and execution logging.


How it works

Step 1: Tag your resources

Resources opt into scheduling with a single tag:

Key:   Schedule
Value: office-hours

To exclude a resource from being stopped (for example, during a deployment):

Key:   KeepRunning
Value: true

Example:

aws ec2 create-tags --resources i-0123456789abcdef0 \
  --tags Key=Schedule,Value=office-hours

aws rds add-tags-to-resource \
  --resource-name arn:aws:rds:af-south-1:123456789012:db:mydb \
  --tags Key=Schedule,Value=office-hours

Step 2: Deploy the Terraform module

The solution is packaged as a Terraform module with the following structure:

terraform-resource-scheduler/
├── versions.tf              # Terraform >= 1.5, AWS ~> 5.0
├── variables.tf             # All inputs with validation blocks
├── main.tf                  # Provider, locals, schedule group
├── iam.tf                   # Two IAM roles + policies
├── ssm_documents.tf         # 5 SSM Automation runbooks
├── schedules.tf             # 10 EventBridge schedules (start + stop × 5)
├── sns.tf                   # Optional email notifications
├── outputs.tf               # Role ARNs, document names, tagging info
└── terraform.tfvars.example # Copy and customize

Deploy:

cd terraform-resource-scheduler
cp terraform.tfvars.example terraform.tfvars
# Edit with your values

terraform init
terraform plan
terraform apply

Key parameters:

ParameterDefaultDescription
start_schedule_expressioncron(0 7 ? * MON-FRI *)When to start resources
stop_schedule_expressioncron(0 19 ? * MON-FRI *)When to stop resources
schedule_timezoneAfrica/JohannesburgIANA timezone
schedule_tag_keyScheduleTag key for targeting
schedule_tag_valueoffice-hoursTag value for targeting
enable_ec2 / rds / redshift / eks / opensearchtrueToggle each resource type
notification_email""Email for SNS alerts (empty disables)

Step 3: Verify execution

After deployment, monitor executions:

aws ssm describe-automation-executions \
  --filters Key=DocumentNamePrefix,Values=resource-scheduler

aws scheduler list-schedules \
  --group-name resource-scheduler-non-prod

Implementation details

SSM Automation Runbooks

Each resource type has a dedicated runbook. The EC2 runbook uses an aws:executeScript step with Python:

import boto3

def handler(event, context):
    ec2 = boto3.client('ec2')
    action = event['Action']
    tag_key = event['TagKey']
    tag_value = event['TagValue']
    override_key = event['OverrideTagKey']

    state_filter = 'running' if action == 'Stop' else 'stopped'

    response = ec2.describe_instances(
        Filters=[
            {'Name': f'tag:{tag_key}', 'Values': [tag_value]},
            {'Name': 'instance-state-name', 'Values': [state_filter]}
        ]
    )

    instance_ids = []
    for reservation in response['Reservations']:
        for instance in reservation['Instances']:
            tags = {t['Key']: t['Value'] for t in instance.get('Tags', [])}
            if action == 'Stop' and tags.get(override_key, '').lower() == 'true':
                continue
            instance_ids.append(instance['InstanceId'])

    if not instance_ids:
        return {'status': 'No instances to process', 'count': 0}

    if action == 'Stop':
        ec2.stop_instances(InstanceIds=instance_ids)
    else:
        ec2.start_instances(InstanceIds=instance_ids)

    return {'status': f'{action} executed', 'count': len(instance_ids)}

The RDS runbook handles both standalone instances and Aurora clusters. The Redshift runbook uses PauseCluster and ResumeCluster. The OpenSearch runbook uses UpdateDomainConfig (requires engine version >= 2.13).

The EKS runbook scales the node group rather than stopping instances:

mainSteps:
  - name: ScaleNodeGroup
    action: aws:executeAwsApi
    inputs:
      Service: eks
      Api: UpdateNodegroupConfig
      clusterName: '{{ ClusterName }}'
      nodegroupName: '{{ NodeGroupName }}'
      scalingConfig:
        desiredSize: 0
        minSize: 0
        maxSize: 5

The EKS control plane remains running (minimal cost). Nodes are terminated and recreated on scale-up.

EventBridge Scheduler configuration

resource "aws_scheduler_schedule" "ec2_start" {
  name                         = "resource-scheduler-ec2-start"
  schedule_expression          = "cron(0 7 ? * MON-FRI *)"
  schedule_expression_timezone = "Africa/Johannesburg"

  flexible_time_window {
    mode = "OFF"
  }

  target {
    arn      = "arn:aws:ssm:${region}:${account_id}:automation-definition/${ssm_doc}"
    role_arn = aws_iam_role.scheduler_execution.arn

    input = jsonencode({
      Action   = ["Start"]
      TagKey   = ["Schedule"]
      TagValue = ["office-hours"]
    })

    retry_policy {
      maximum_retry_attempts       = 2
      maximum_event_age_in_seconds = 3600
    }
  }
}

IAM roles

The solution uses two roles following the principle of least privilege:

Scheduler Execution Role (assumed by EventBridge Scheduler):

  • ssm:StartAutomationExecution — invoke SSM Automation documents
  • iam:PassRole — restricted to the SSM automation role ARN

SSM Automation Role (assumed during runbook execution):

  • Start/stop permissions for each resource type (EC2, RDS, Redshift, EKS, OpenSearch)
  • tag:GetResources for tag-based discovery
  • sns:Publish for notifications

No wildcard actions. Role separation ensures the scheduler cannot directly call resource APIs.


Design considerations

Timezone handling

Configure schedules in your local timezone. EventBridge Scheduler supports IANA timezones natively and handles DST transitions. Using UTC leads to schedules that shift by an hour during daylight saving changes.

Override mechanism

Add the KeepRunning:true tag to any resource to exclude it from stop actions. The runbook checks this tag before executing. Remove the tag to resume scheduling.

For time-limited overrides, use KeepRunningUntil:YYYY-MM-DD (requires separate cleanup logic or a Config rule).

Graceful shutdown

  • EC2: Receives a standard OS-level stop signal. Applications should handle SIGTERM.
  • EKS: Pod disruption budgets are respected during node termination.
  • RDS: Connections are terminated gracefully during stop.

For critical workloads, add an SSM Run Command step before stop to flush caches or drain connections.

Tag enforcement

Pair the scheduler with an AWS Config rule to ensure all non-production resources carry the Schedule tag:

resource "aws_config_config_rule" "require_schedule_tag" {
  name = "require-schedule-tag-nonprod"
  source {
    owner             = "AWS"
    source_identifier = "REQUIRED_TAGS"
  }
  input_parameters = jsonencode({
    tag1Key   = "Schedule"
    tag1Value = "office-hours"
  })
}

Rollout plan

PhaseTimelineScope
PilotWeek 1–25–10 non-critical EC2 instances
ExpandWeek 3–4All EC2 and RDS in one account
Full servicesWeek 5–6Add Redshift, EKS, OpenSearch
ScaleWeek 7+All non-prod accounts; enable tag enforcement

Success criteria: zero unintended stoppages, all scheduled resources start on time, cost reduction visible in Cost Explorer within one billing cycle.


Conclusion

This solution provides zero-cost, zero-maintenance scheduling for five AWS resource types. It deploys in under 15 minutes via Terraform, targets resources through tags, and delivers approximately $29,000 in annual savings for a typical 50-instance non-production fleet.

The combination of EventBridge Scheduler and SSM Automation eliminates the operational overhead of Lambda-based schedulers while providing native timezone support, built-in retry, and complete execution audit trails.


Questions about this approach? Leave a comment below or reach out on the AWS re:Post community.